From e9b883cec557e2d94666b77e3d1b9733ac69869c Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 30 Mar 2017 15:47:42 -0400 Subject: [PATCH 001/161] (Hopefully) cleaner model and Promise management in GithubPackage --- lib/github-package.js | 99 +++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 61 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 83f9390a9d..0e8fa8d835 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -34,6 +34,8 @@ export default class GithubPackage { this.modelPromisesByProjectPath = new Map(); this.modelsByProjectPath = new Map(); this.confirm = confirm; + + this.activeProjectPath = null; this.activeRepository = null; this.activeResolutionProgress = null; @@ -46,9 +48,9 @@ export default class GithubPackage { this.subscriptions = new CompositeDisposable(); const projectPaths = this.project.getDirectories().map(dir => dir.getRealPathSync()); - const initPromises = this.cacheModelsForPaths(projectPaths); - this.initWatchersStartedPromise = initPromises.changeObserversStartedPromise; - this.initModelsPromise = initPromises.modelsPromise; + if (projectPaths.length > 0) { + this.didChangeProjectPaths(projectPaths); + } this.subscriptions.add( this.project.onDidChangePaths(this.didChangeProjectPaths), @@ -102,23 +104,8 @@ export default class GithubPackage { } async deserialize(state) { - // Ensure that Repository promises are available - await this.initModelsPromise; - let nextActiveRepository, nextActiveResolutionProgress; - if (state.activeRepositoryPath) { - const repository = await this.getRepositoryForWorkdirPath(state.activeRepositoryPath); - if (repository) { nextActiveRepository = repository; } - } - - if (nextActiveRepository) { - const savedResolutionProgress = this.savedState.resolutionProgressByPath[state.activeRepositoryPath] || {}; - const commit = await nextActiveRepository.getLastCommit(); - nextActiveResolutionProgress = new ResolutionProgress(commit.sha, savedResolutionProgress); - - this.setActiveModels(nextActiveRepository, nextActiveResolutionProgress); - } else { - await this.updateActiveModels(); + await this.didChangeProjectPaths([state.activeRepositoryPath]); } } @@ -170,11 +157,13 @@ export default class GithubPackage { @autobind async didChangeProjectPaths(projectPaths) { - this.updateActiveModels(); const previousProjectPaths = Array.from(this.modelPromisesByProjectPath.keys()); const {added, removed} = compareSets(new Set(previousProjectPaths), new Set(projectPaths)); - this.cacheModelsForPaths(Array.from(added)); - await this.destroyModelsForPaths(Array.from(removed)); + await Promise.all([ + this.cacheModelsForPaths(Array.from(added)), + this.destroyModelsForPaths(Array.from(removed)), + ]); + await this.updateActiveModels(); } @autobind @@ -200,14 +189,6 @@ export default class GithubPackage { this.updateActiveModels(); } - getInitialWatchersStartedPromise() { - return this.initWatchersStartedPromise; - } - - getInitialModelsPromise() { - return this.initModelsPromise; - } - getActiveProjectPath() { return this.activeProjectPath; } @@ -221,8 +202,9 @@ export default class GithubPackage { } async updateActiveModels() { - await this.initModelsPromise; - let nextActiveRepository, nextActiveResolutionProgress, nextActiveProjectPath; + let nextActiveRepository = null; + let nextActiveResolutionProgress = null; + let nextActiveProjectPath = null; const activeItem = this.workspace.getActivePaneItem(); if (activeItem && typeof activeItem.getPath === 'function') { @@ -251,32 +233,38 @@ export default class GithubPackage { if (!activeItem && this.project.getPaths().length === 1) { nextActiveProjectPath = this.project.getPaths()[0]; nextActiveRepository = await this.getRepositoryForWorkdirPath(nextActiveProjectPath); + nextActiveResolutionProgress = await this.getResolutionProgressForWorkdirPath(nextActiveProjectPath); } - if (nextActiveRepository && nextActiveRepository !== this.activeRepository) { - this.activeProjectPath = nextActiveProjectPath || null; - this.setActiveModels(nextActiveRepository, nextActiveResolutionProgress); + this.setActiveModels(nextActiveProjectPath, nextActiveRepository, nextActiveResolutionProgress); + } + + setActiveModels(projectPath, repository, resolutionProgress) { + // Ensure that models are updated again when a non-null repository is destroyed + if (repository) { + if (this.destroyedRepositorySubscription) { + this.destroyedRepositorySubscription.dispose(); + } - if (this.destroyedRepositorySubscription) { this.destroyedRepositorySubscription.dispose(); } - this.destroyedRepositorySubscription = nextActiveRepository.onDidDestroy(() => { - if (nextActiveRepository === this.activeRepository) { - this.setActiveModels(null); + this.destroyedRepositorySubscription = repository.onDidDestroy(() => { + if (repository === this.activeRepository) { + this.setActiveModels(null, null, null); } this.destroyedRepositorySubscription.dispose(); }); - } else if (nextActiveRepository && this.activeProjectPath !== nextActiveProjectPath) { - this.activeProjectPath = nextActiveProjectPath || null; - this.rerender(); - } else if (!nextActiveRepository) { - this.activeProjectPath = nextActiveProjectPath || null; - this.setActiveModels(null, null); } - } - setActiveModels(repository, resolutionProgress) { + const isChanging = this.activeProjectPath !== projectPath || + this.activeRepository !== repository || + this.activeResolutionProgress !== resolutionProgress; + + this.activeProjectPath = projectPath; this.activeRepository = repository; this.activeResolutionProgress = resolutionProgress; - this.rerender(); + + if (isChanging) { + this.rerender(); + } } refreshAtomGitRepository(repository) { @@ -313,24 +301,15 @@ export default class GithubPackage { } cacheModelsForPaths(addedPaths) { - const changeObserverPromises = []; - const modelsPromises = []; - const ready = Promise.all(addedPaths.map(async projectPath => { + return Promise.all(addedPaths.map(async projectPath => { const repository = await Repository.open(projectPath); if (!repository) { return; } - const {modelPromise, changeObserverPromise} = this.addRepository(repository); - modelsPromises.push(modelPromise); - changeObserverPromises.push(changeObserverPromise); + this.addRepository(repository); })); - - return { - changeObserversStartedPromise: ready.then(() => Promise.all(changeObserverPromises)), - modelsPromise: ready.then(() => Promise.all(modelsPromises)), - }; } addRepository(repository) { @@ -373,8 +352,6 @@ export default class GithubPackage { changeObserverPromise, resolutionProgressPromise, }); - - return {modelPromise: resolutionProgressPromise, changeObserverPromise}; } destroyModelsForPaths(removedPaths) { From fed769c6358909e85a0a1cc75082ebafe386dd5c Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Sat, 1 Apr 2017 11:37:14 -0400 Subject: [PATCH 002/161] Rework unit tests to avoid getInitialModelsPromise() --- test/github-package.test.js | 62 +++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/test/github-package.test.js b/test/github-package.test.js index 9d9ccfb67b..3fbcd5e9cc 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -32,6 +32,37 @@ describe('GithubPackage', function() { atomEnv.destroy(); }); + // Create a Promise that will resolve successfully when all models for all expected working directories have been + // initialized. + function untilModelsReady(workdirs, models = ['respository', 'changeObserver', 'resolutionProgress'], pkg = githubPackage) { + const modelPromiseGetters = models.map(modelName => { + return `get${modelName[0].toUpperCase() + modelName.slice(1)}ForWorkdirPath`; + }); + + const modelReadyForWorkdir = (getter, workdir) => new Promise(resolve => { + const attempt = () => { + pkg[getter](workdir).then(result => { + if (result === null) { + setTimeout(attempt, 100); + } else { + resolve(); + } + }); + }; + attempt(); + }); + + const readyPromises = []; + for (let i = 0; i < workdirs.length; i++) { + const workdir = workdirs[i]; + for (let j = 0; j < modelPromiseGetters.length; j++) { + const getter = modelPromiseGetters[j]; + readyPromises.push(modelReadyForWorkdir(getter, workdir)); + } + } + return Promise.all(readyPromises); + } + describe('activate()', function() { it('updates the active repository', async function() { await githubPackage.activate(); @@ -88,7 +119,8 @@ describe('GithubPackage', function() { const workdirPath3 = await cloneRepository('three-files'); project.setPaths([workdirPath1, workdirPath2, workdirPath3]); await githubPackage.activate(); - await githubPackage.getInitialModelsPromise(); + + await untilModelsReady([workdirPath1, workdirPath2, workdirPath3], ['repository']); const repository1 = await githubPackage.getRepositoryForWorkdirPath(workdirPath1); const repository2 = await githubPackage.getRepositoryForWorkdirPath(workdirPath2); @@ -204,7 +236,7 @@ describe('GithubPackage', function() { project.setPaths([workdirMergeConflict, workdirNoConflict, nonRepositoryPath]); await githubPackage.activate(); - await githubPackage.getInitialModelsPromise(); + await untilModelsReady([workdirMergeConflict, workdirNoConflict], ['resolutionProgress']); // Open a file in the merge conflict repository. await workspace.open(path.join(workdirMergeConflict, 'modified-on-both-ours.txt')); @@ -241,12 +273,13 @@ describe('GithubPackage', function() { // Don't worry about this on Windows as it's not a common op if (process.platform !== 'win32') { - it('handles symlinked project paths', async () => { + it('handles symlinked project paths', async function() { const workdirPath = await cloneRepository('three-files'); const symlinkPath = fs.realpathSync(temp.mkdirSync()) + '-symlink'; fs.symlinkSync(workdirPath, symlinkPath); project.setPaths([symlinkPath]); await githubPackage.activate(); + await untilModelsReady([workdirPath], ['repository']); await workspace.open(path.join(symlinkPath, 'a.txt')); @@ -269,7 +302,24 @@ describe('GithubPackage', function() { project.setPaths([workdirPath1, workdirPath2]); await githubPackage.activate(); - await githubPackage.getInitialWatchersStartedPromise(); + + // Wait for all change observers to be present. + const changeObserverForWorkdir = workdirPath => new Promise(resolve => { + const attempt = () => { + githubPackage.getChangeObserverForWorkdirPath(workdirPath).then(result => { + if (result === null) { + setTimeout(attempt, 100); + } else { + resolve(); + } + }); + }; + attempt(); + }); + await Promise.all( + [workdirPath1, workdirPath2].map(workdirPath => changeObserverForWorkdir(workdirPath)), + ); + [atomGitRepository1, atomGitRepository2] = githubPackage.project.getRepositories(); sinon.stub(atomGitRepository1, 'refreshStatus'); sinon.stub(atomGitRepository2, 'refreshStatus'); @@ -356,7 +406,7 @@ describe('GithubPackage', function() { project.setPaths([workdirMergeConflict, workdirNoConflict]); await githubPackage.activate(); - await githubPackage.getInitialModelsPromise(); + await untilModelsReady([workdirMergeConflict, workdirNoConflict], ['resolutionProgress']); // Record some state to recover later. const resolutionMergeConflict0 = await githubPackage.getResolutionProgressForWorkdirPath(workdirMergeConflict); @@ -373,7 +423,7 @@ describe('GithubPackage', function() { workspace, project, commandRegistry, notificationManager, tooltips, styles, config, confirm, ); await githubPackage1.activate(payload); - await githubPackage1.getInitialModelsPromise(); + await untilModelsReady([workdirMergeConflict, workdirNoConflict], ['resolutionProgress'], githubPackage1); const resolutionMergeConflict1 = await githubPackage1.getResolutionProgressForWorkdirPath(workdirMergeConflict); const resolutionNoConflict1 = await githubPackage1.getResolutionProgressForWorkdirPath(workdirNoConflict); From 909b6033ab4f1c658c101af0e3f03cca3bc60200 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 5 Apr 2017 10:57:08 -0400 Subject: [PATCH 003/161] (temporary) console.logstravaganza --- lib/github-package.js | 4 ++++ test/github-package.test.js | 1 + 2 files changed, 5 insertions(+) diff --git a/lib/github-package.js b/lib/github-package.js index 8bee860750..b65ecf41b9 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -190,6 +190,7 @@ export default class GithubPackage { @autobind didChangeActivePaneItem() { + console.log('didChangeActivePaneItem triggered'); this.updateActiveModels(); } @@ -211,14 +212,17 @@ export default class GithubPackage { let nextActiveProjectPath = null; const activeItem = this.workspace.getActivePaneItem(); + console.log(`updateActiveModels: activeItem is ${!!activeItem} and activeItem.getPath is ${activeItem && typeof activeItem.getPath}`); if (activeItem && typeof activeItem.getPath === 'function') { const projectPath = this.projectPathForItemPath(activeItem.getPath()); + console.log(`updateActiveModels: projectPath is ${projectPath}`); if (projectPath) { nextActiveProjectPath = projectPath; await Promise.all([ this.getRepositoryForWorkdirPath(projectPath).then(r => { nextActiveRepository = r; }), this.getResolutionProgressForWorkdirPath(projectPath).then(rp => { nextActiveResolutionProgress = rp; }), ]); + console.log(`updateActiveModels: nextActiveRepository is ${nextActiveRepository} nextActiveResolutionProgress is ${nextActiveResolutionProgress}`); } } else { nextActiveRepository = this.activeRepository; diff --git a/test/github-package.test.js b/test/github-package.test.js index 3fbcd5e9cc..d4f147bf14 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -107,6 +107,7 @@ describe('GithubPackage', function() { await assert.async.equal(githubPackage.getActiveProjectPath(), workdirPath2); await assert.async.equal(githubPackage.rerender.callCount, 3); + console.log('--- About to open c.txt ---'); await workspace.open(path.join(nonRepositoryPath, 'c.txt')); await assert.async.isNull(githubPackage.getActiveRepository()); await assert.async.equal(githubPackage.getActiveProjectPath(), nonRepositoryPath); From bf1cd091e9e09cffa6e62c970ddbbbf4300dd075 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 5 Apr 2017 14:10:45 -0400 Subject: [PATCH 004/161] Use an AsyncQueue for model updates --- lib/github-package.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index b65ecf41b9..de48a106e9 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -16,6 +16,7 @@ import FilePatchController from './controllers/file-patch-controller'; import RootController from './controllers/root-controller'; import IssueishPaneItem from './atom-items/issueish-pane-item'; import GitTimingsView from './views/git-timings-view'; +import AsyncQueue from './async-queue'; const defaultState = { resolutionProgressByPath: {}, @@ -36,6 +37,7 @@ export default class GithubPackage { this.modelsByProjectPath = new Map(); this.confirm = confirm; + this.activeModelQueue = new AsyncQueue(); this.activeProjectPath = null; this.activeRepository = null; this.activeResolutionProgress = null; @@ -167,7 +169,7 @@ export default class GithubPackage { this.cacheModelsForPaths(Array.from(added)), this.destroyModelsForPaths(Array.from(removed)), ]); - await this.updateActiveModels(); + await this.scheduleActiveModelUpdate(); } @autobind @@ -177,7 +179,7 @@ export default class GithubPackage { } const repository = await Repository.init(projectPath); this.addRepository(repository); - await this.updateActiveModels(); + await this.scheduleActiveModelUpdate(); } @autobind @@ -185,13 +187,12 @@ export default class GithubPackage { const repository = await Repository.clone(remoteUrl, projectPath); this.addRepository(repository); this.project.addPath(projectPath); - await this.updateActiveModels(); + await this.scheduleActiveModelUpdate(); } @autobind - didChangeActivePaneItem() { - console.log('didChangeActivePaneItem triggered'); - this.updateActiveModels(); + async didChangeActivePaneItem() { + await this.scheduleActiveModelUpdate(); } getActiveProjectPath() { @@ -206,23 +207,25 @@ export default class GithubPackage { return this.activeResolutionProgress; } + async scheduleActiveModelUpdate() { + await this.activeModelQueue.push(this.updateActiveModels, {parallel: false}); + } + + @autobind async updateActiveModels() { let nextActiveRepository = null; let nextActiveResolutionProgress = null; let nextActiveProjectPath = null; const activeItem = this.workspace.getActivePaneItem(); - console.log(`updateActiveModels: activeItem is ${!!activeItem} and activeItem.getPath is ${activeItem && typeof activeItem.getPath}`); if (activeItem && typeof activeItem.getPath === 'function') { const projectPath = this.projectPathForItemPath(activeItem.getPath()); - console.log(`updateActiveModels: projectPath is ${projectPath}`); if (projectPath) { nextActiveProjectPath = projectPath; await Promise.all([ this.getRepositoryForWorkdirPath(projectPath).then(r => { nextActiveRepository = r; }), this.getResolutionProgressForWorkdirPath(projectPath).then(rp => { nextActiveResolutionProgress = rp; }), ]); - console.log(`updateActiveModels: nextActiveRepository is ${nextActiveRepository} nextActiveResolutionProgress is ${nextActiveResolutionProgress}`); } } else { nextActiveRepository = this.activeRepository; From a1d9fca4fbddc199e0f68b5b15b89182e9b09cdf Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 5 Apr 2017 14:11:42 -0400 Subject: [PATCH 005/161] Use .scheduleActiveModelUpdate() --- test/github-package.test.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test/github-package.test.js b/test/github-package.test.js index d4f147bf14..365ad82ff3 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -176,43 +176,43 @@ describe('GithubPackage', function() { await workspace.open(path.join(workdirPath1, 'a.txt')); await workspace.open(path.join(workdirPath2, 'b.txt')); - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); assert.isNotNull(githubPackage.getActiveRepository()); assert.equal(githubPackage.getActiveProjectPath(), workdirPath2); assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath2)); await workspace.open(path.join(nonRepositoryPath, 'c.txt')); - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); assert.equal(githubPackage.getActiveProjectPath(), nonRepositoryPath); assert.isNull(githubPackage.getActiveRepository()); await workspace.open(path.join(workdirPath1, 'a.txt')); - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); assert.equal(githubPackage.getActiveProjectPath(), workdirPath1); assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath1)); workspace.getActivePane().activateItem({}); // such as when find & replace results pane is focused - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); assert.equal(githubPackage.getActiveProjectPath(), workdirPath1); assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath1)); await workspace.open(path.join(workdirPath2, 'b.txt')); - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); assert.equal(githubPackage.getActiveProjectPath(), workdirPath2); assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath2)); project.removePath(workdirPath2); - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); assert.isNull(githubPackage.getActiveProjectPath()); assert.isNull(githubPackage.getActiveRepository()); project.removePath(workdirPath1); - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); assert.isNull(githubPackage.getActiveProjectPath()); assert.isNull(githubPackage.getActiveRepository()); await workspace.open(path.join(workdirPath1, 'a.txt')); - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); assert.isNull(githubPackage.getActiveProjectPath()); assert.isNull(githubPackage.getActiveRepository()); }); @@ -222,7 +222,7 @@ describe('GithubPackage', function() { project.setPaths([workdirPath]); await githubPackage.activate(); - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); const repository = await githubPackage.getRepositoryForWorkdirPath(workdirPath); await assert.async.strictEqual(githubPackage.getActiveRepository(), repository); @@ -241,7 +241,7 @@ describe('GithubPackage', function() { // Open a file in the merge conflict repository. await workspace.open(path.join(workdirMergeConflict, 'modified-on-both-ours.txt')); - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); const resolutionMergeConflict = await githubPackage.getResolutionProgressForWorkdirPath(workdirMergeConflict); await assert.strictEqual(githubPackage.getActiveResolutionProgress(), resolutionMergeConflict); @@ -251,7 +251,7 @@ describe('GithubPackage', function() { // Open a file in the non-merge conflict repository. await workspace.open(path.join(workdirNoConflict, 'b.txt')); - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); const resolutionNoConflict = await githubPackage.getResolutionProgressForWorkdirPath(workdirNoConflict); await assert.strictEqual(githubPackage.getActiveResolutionProgress(), resolutionNoConflict); @@ -259,13 +259,13 @@ describe('GithubPackage', function() { // Open a file in the workdir with no repository. await workspace.open(path.join(nonRepositoryPath, 'c.txt')); - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); await assert.isNull(githubPackage.getActiveResolutionProgress()); // Re-open a file in the merge conflict repository. await workspace.open(path.join(workdirMergeConflict, 'modified-on-both-theirs.txt')); - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); await assert.strictEqual(githubPackage.getActiveResolutionProgress(), resolutionMergeConflict); assert.isFalse(githubPackage.getActiveResolutionProgress().isEmpty()); @@ -284,7 +284,7 @@ describe('GithubPackage', function() { await workspace.open(path.join(symlinkPath, 'a.txt')); - await githubPackage.updateActiveModels(); + await githubPackage.scheduleActiveModelUpdate(); assert.isOk(githubPackage.getActiveRepository()); }); } From fc4e67822318153e3a63f7e247d929272f4c55cd Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 5 Apr 2017 14:16:18 -0400 Subject: [PATCH 006/161] :fire: last console.log --- test/github-package.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/github-package.test.js b/test/github-package.test.js index 365ad82ff3..3d5e61114b 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -107,7 +107,6 @@ describe('GithubPackage', function() { await assert.async.equal(githubPackage.getActiveProjectPath(), workdirPath2); await assert.async.equal(githubPackage.rerender.callCount, 3); - console.log('--- About to open c.txt ---'); await workspace.open(path.join(nonRepositoryPath, 'c.txt')); await assert.async.isNull(githubPackage.getActiveRepository()); await assert.async.equal(githubPackage.getActiveProjectPath(), nonRepositoryPath); From 872f5c0e0a15c27e8fc98574edb05145a0d23159 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 6 Apr 2017 10:57:06 -0400 Subject: [PATCH 007/161] Introduce a WorkdirContext to bundle workdir-specific models --- lib/models/workdir-context.js | 176 ++++++++++++++++++++++++++++ test/models/workdir-context.test.js | 154 ++++++++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 lib/models/workdir-context.js create mode 100644 test/models/workdir-context.test.js diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js new file mode 100644 index 0000000000..0838254680 --- /dev/null +++ b/lib/models/workdir-context.js @@ -0,0 +1,176 @@ +import {Emitter, CompositeDisposable} from 'atom'; // FIXME import from event-kit + +import Repository from './repository'; +import ResolutionProgress from './conflicts/resolution-progress'; +import FileSystemChangeObserver from './file-system-change-observer'; +import WorkspaceChangeObserver from './workspace-change-observer'; + +// Provide both synchronous and asynchronous access to a value. +class Deferred { + constructor(value = null) { + if (value !== null) { + this.value = value; + this.promise = Promise.resolve(value); + this.resolve = () => { + throw new Error('Attempt to resolve Deferred with concrete value'); + }; + this.reject = () => { + throw new Error('Attempt to reject Deferred with concrete value'); + }; + } else { + this.value = null; + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } + } + + hasValue() { + return this.value !== null; + } + + setValue(value) { + this.value = value; + this.resolve(value); + } + + getValue() { + return this.value; + } + + getPromise() { + return this.promise; + } +} + +/* + * Bundle of model objects associated with a git working directory. + * + * Provides synchronous access to each model in the form of a getter method that returns the model or `null` if it + * has not yet been initialized, and asynchronous access in the form of a Promise generation method that will resolve + * once the model is available. Initializes the platform-appropriate change observer and proxies select filesystem + * change events. + */ +export default class WorkdirContext { + + /* + * Available options: + * - `options.repository`: Pre-initialized Repository instance if one is available. + * - `options.savedResolutionProgress`: Serialized state used to initialize the ResolutionProgress. + * - `options.window`: Browser window global, used on Linux by the WorkspaceChangeObserver. + * - `options.workspace`: Atom's workspace singleton, used on Linux by the WorkspaceChangeObserver. + */ + constructor(directory, options = {}) { + this.directory = directory; + this.options = options; + + this.repositoryHolder = new Deferred(this.options.repository || null); + this.changeObserverHolder = new Deferred(); + this.resolutionProgressHolder = new Deferred(); + + this.destroyed = false; + this.emitter = new Emitter(); + this.subs = new CompositeDisposable(); + } + + /* + * Initialize all model objects associated with this git working directory. Return a Promise that will resolve when + * all models are available and ready, including the filesystem watcher. Outside of test cases, it's generally + * better to not await this Promise and instead await the `getXyzPromise()` of the models that you need. + */ + async create() { + if (this.destroyed) { + return; + } + + let repo; + if (!this.repositoryHolder.hasValue()) { + repo = await Repository.open(this.directory); + this.repositoryHolder.setValue(repo); + } else { + repo = this.getRepository(); + } + + const changeObserver = process.platform === 'linux' + ? new WorkspaceChangeObserver(this.options.window, this.options.workspace, repo) + : new FileSystemChangeObserver(repo); + + this.subs.add(changeObserver.onDidChange(() => repo.refresh())); + this.subs.add(changeObserver.onDidChangeWorkdirOrHead(() => this.emitter.emit('did-change-workdir-or-head'))); + + this.changeObserverHolder.setValue(changeObserver); + + const populateResolutionProgress = async () => { + const commit = await repo.getLastCommit(); + const progress = new ResolutionProgress(commit.sha, this.options.savedResolutionProgress || {}); + this.resolutionProgressHolder.setValue(progress); + }; + + await Promise.all([ + populateResolutionProgress(), + changeObserver.start(), + ]); + } + + isDestroyed() { + return this.destroyed; + } + + /* + * Subscribe to the filesystem observer event. + */ + onDidChangeWorkdirOrHead(callback) { + return this.emitter.on('did-change-workdir-or-head', callback); + } + + getRepository() { + return this.repositoryHolder.getValue(); + } + + getRepositoryPromise() { + return this.repositoryHolder.getPromise(); + } + + getChangeObserver() { + return this.changeObserverHolder.getValue(); + } + + getChangeObserverPromise() { + return this.changeObserverHolder.getPromise(); + } + + getResolutionProgress() { + return this.resolutionProgressHolder.getValue(); + } + + getResolutionProgressPromise() { + return this.resolutionProgressHolder.getPromise(); + } + + /* + * Cleanly destroy any models that need to be cleaned, including stopping the filesystem watcher. + */ + async destroy() { + if (this.destroyed) { + return; + } + + this.destroyed = true; + this.subs.dispose(); + this.emitter.dispose(); + + const observer = this.getChangeObserver(); + const repository = this.getRepository(); + + const promises = []; + if (observer) { + promises.push(observer.destroy()); + } + if (repository) { + promises.push(repository.destroy()); + } + + await Promise.all(promises); + } +} diff --git a/test/models/workdir-context.test.js b/test/models/workdir-context.test.js new file mode 100644 index 0000000000..9586266427 --- /dev/null +++ b/test/models/workdir-context.test.js @@ -0,0 +1,154 @@ +import {CompositeDisposable} from 'atom'; + +import {cloneRepository} from '../helpers'; + +import WorkdirContext from '../../lib/models/workdir-context'; +import Repository from '../../lib/models/repository'; + +describe('WorkdirContext', function() { + let context, workingDirectory, subs; + + beforeEach(async function() { + workingDirectory = await cloneRepository('three-files'); + subs = new CompositeDisposable(); + }); + + afterEach(async function() { + context && await context.destroy(); + subs.dispose(); + }); + + describe('without an initial Repository', function() { + beforeEach(function() { + context = new WorkdirContext(workingDirectory); + }); + + it('returns null for each synchronous model', function() { + assert.isNull(context.getRepository()); + assert.isNull(context.getResolutionProgress()); + assert.isNull(context.getChangeObserver()); + }); + + it('populates the repository on create()', async function() { + const repoPromise = context.getRepositoryPromise(); + + await context.create(); + + const repo = context.getRepository(); + assert.isNotNull(repo); + assert.equal(repo.getWorkingDirectoryPath(), workingDirectory); + assert.strictEqual(repo, await repoPromise); + }); + + it('populates the resolution progress on create()', async function() { + const progressPromise = context.getResolutionProgressPromise(); + + await context.create(); + + const progress = context.getResolutionProgress(); + assert.isNotNull(progress); + assert.strictEqual(progress, await progressPromise); + }); + + it('populates the change observer on create()', async function() { + const observerPromise = context.getChangeObserverPromise(); + + await context.create(); + + const observer = context.getChangeObserver(); + assert.isNotNull(observer); + assert.strictEqual(observer, await observerPromise); + }); + + it('refreshes the repository on any filesystem change', async function() { + await context.create(); + + const repo = context.getRepository(); + sinon.spy(repo, 'refresh'); + + const observer = context.getChangeObserver(); + observer.emitter.emit('did-change'); + + assert.isTrue(repo.refresh.called); + }); + + it('emits an event on workdir or head change', async function() { + const listener = sinon.spy(); + subs.add(context.onDidChangeWorkdirOrHead(listener)); + + await context.create(); + + context.getChangeObserver().emitter.emit('did-change-workdir-or-head'); + assert.isTrue(listener.called); + }); + + it('can be destroyed before create()', async function() { + await context.destroy(); + assert.isTrue(context.isDestroyed()); + + await context.create(); + assert.isTrue(context.isDestroyed()); + }); + + it('destroys the repository on destroy()', async function() { + await context.create(); + const repo = context.getRepository(); + sinon.spy(repo, 'destroy'); + + await context.destroy(); + assert.isTrue(repo.destroy.called); + }); + + it('stops the change observer on destroy()', async function() { + await context.create(); + const observer = context.getChangeObserver(); + await observer.start(); + + sinon.spy(observer, 'destroy'); + + await context.destroy(); + assert.isTrue(observer.destroy.called); + }); + + it('can be destroyed twice', async function() { + await context.create(); + assert.isFalse(context.isDestroyed()); + + await context.destroy(); + assert.isTrue(context.isDestroyed()); + }); + }); + + describe('with an initial Repository', function() { + let repository; + + beforeEach(async function() { + repository = await Repository.open(workingDirectory); + context = new WorkdirContext(workingDirectory, {repository}); + }); + + it('returns the repository synchrounsly', function() { + assert.strictEqual(repository, context.getRepository()); + }); + + it('populates the resolution progress on create()', async function() { + const progressPromise = context.getResolutionProgressPromise(); + + await context.create(); + + const progress = context.getResolutionProgress(); + assert.isNotNull(progress); + assert.strictEqual(progress, await progressPromise); + }); + + it('populates the change observer on create()', async function() { + const observerPromise = context.getChangeObserverPromise(); + + await context.create(); + + const observer = context.getChangeObserver(); + assert.isNotNull(observer); + assert.strictEqual(observer, await observerPromise); + }); + }); +}); From 765bf0efd4fc3f6dc8b08655131a45741fd446ba Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 6 Apr 2017 16:15:23 -0400 Subject: [PATCH 008/161] Repository.destroy() is synchronous --- lib/models/workdir-context.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index 0838254680..70d8b42f95 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -163,14 +163,10 @@ export default class WorkdirContext { const observer = this.getChangeObserver(); const repository = this.getRepository(); - const promises = []; + repository && repository.destroy(); + if (observer) { - promises.push(observer.destroy()); - } - if (repository) { - promises.push(repository.destroy()); + await observer.destroy(); } - - await Promise.all(promises); } } From 1ec5774039ae34459a3ef26777cd3258f9c35683 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 6 Apr 2017 16:16:51 -0400 Subject: [PATCH 009/161] WorkdirContextPool to manage the set of WorkdirContexts --- lib/models/workdir-context-pool.js | 58 ++++++++++ test/models/workdir-context-pool.test.js | 130 +++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 lib/models/workdir-context-pool.js create mode 100644 test/models/workdir-context-pool.test.js diff --git a/lib/models/workdir-context-pool.js b/lib/models/workdir-context-pool.js new file mode 100644 index 0000000000..af32e19d1f --- /dev/null +++ b/lib/models/workdir-context-pool.js @@ -0,0 +1,58 @@ +import compareSets from 'compare-sets'; + +import WorkdirContext from './workdir-context'; + +/** + * Manage a WorkdirContext for each open directory. + */ +export default class WorkdirContextPool { + + /** + * Options will be passed to each `WorkdirContext` as it is created. + */ + constructor(options = {}) { + this.options = options; + + this.contexts = new Map(); + } + + size() { + return this.contexts.size; + } + + /** + * Access the context mapped to a known directory. + */ + getContext(directory) { + return this.contexts.get(directory); + } + + add(directory, options = {}) { + if (this.contexts.has(directory)) { + return; + } + + const context = new WorkdirContext(directory, {...this.options, ...options}); + this.contexts.set(directory, context); + + context.create(); + } + + remove(directory) { + const existing = this.contexts.get(directory); + this.contexts.delete(directory); + existing && existing.destroy(); + } + + set(directories) { + const previous = new Set(this.contexts.keys()); + const {added, removed} = compareSets(previous, directories); + + for (const directory of added) { + this.add(directory); + } + for (const directory of removed) { + this.remove(directory); + } + } +} diff --git a/test/models/workdir-context-pool.test.js b/test/models/workdir-context-pool.test.js new file mode 100644 index 0000000000..2ea6ceb6b2 --- /dev/null +++ b/test/models/workdir-context-pool.test.js @@ -0,0 +1,130 @@ +import {cloneRepository} from '../helpers'; + +import Repository from '../../lib/models/repository'; +import WorkdirContextPool from '../../lib/models/workdir-context-pool'; + +describe('WorkdirContextPool', function() { + let atomEnv, workspace, pool; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + workspace = atomEnv.workspace; + + pool = new WorkdirContextPool({ + window, workspace, + }); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + describe('add', function() { + let workingDirectory; + + beforeEach(async function() { + workingDirectory = await cloneRepository('three-files'); + }); + + it('adds a WorkdirContext for a new working directory', function() { + assert.equal(pool.size(), 0); + assert.isUndefined(pool.getContext(workingDirectory)); + + pool.add(workingDirectory); + + assert.equal(pool.size(), 1); + assert.isDefined(pool.getContext(workingDirectory)); + }); + + it('optionally provides a preinitialized repository', async function() { + const existingRepo = await Repository.open(workingDirectory); + + pool.add(workingDirectory, {repository: existingRepo}); + + const addedRepo = pool.getContext(workingDirectory).getRepository(); + assert.strictEqual(addedRepo, existingRepo); + }); + + it('is a no-op if the working directory already has a context', function() { + pool.add(workingDirectory); + assert.equal(pool.size(), 1); + + const context = pool.getContext(workingDirectory); + assert.isDefined(context); + + pool.add(workingDirectory); + assert.equal(pool.size(), 1); + }); + + it('begins but does not await the asynchronous initialization process', async function() { + pool.add(workingDirectory); + const context = pool.getContext(workingDirectory); + assert.isNull(context.getRepository()); + + const repo = await context.getRepositoryPromise(); + assert.isNotNull(repo); + }); + }); + + describe('remove', function() { + let existingDirectory, existingContext; + + beforeEach(async function() { + existingDirectory = await cloneRepository('three-files'); + pool.add(existingDirectory); + + existingContext = pool.getContext(existingDirectory); + }); + + it('removes a WorkdirContext for an existing working directory', function() { + assert.equal(pool.size(), 1); + pool.remove(existingDirectory); + assert.isUndefined(pool.getContext(existingDirectory)); + assert.equal(pool.size(), 0); + }); + + it('is a no-op if the working directory is not present', function() { + assert.equal(pool.size(), 1); + pool.remove('/nope'); + assert.equal(pool.size(), 1); + }); + + it('begins but does not await the termination process', async function() { + const repo = await existingContext.getRepositoryPromise(); + sinon.spy(repo, 'destroy'); + + assert.isFalse(existingContext.isDestroyed()); + pool.remove(existingDirectory); + assert.isTrue(existingContext.isDestroyed()); + assert.isTrue(repo.destroy.called); + }); + }); + + describe('set', function() { + let dir0, dir1, dir2; + + beforeEach(async function() { + [dir0, dir1, dir2] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); + + pool.add(dir0); + pool.add(dir1); + }); + + it('adds new directories, removes missing ones, and maintains kept ones', function() { + const context0 = pool.getContext(dir0); + const context1 = pool.getContext(dir1); + + pool.set([dir1, dir2]); + + assert.equal(pool.size(), 2); + + assert.isTrue(context0.isDestroyed()); + assert.isFalse(context1.isDestroyed()); + assert.isDefined(pool.getContext(dir2)); + }); + }); +}); From 3cbfefb74b0fcda4ca4ea49f4f1ca872a3acdbdf Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 7 Apr 2017 07:52:30 -0400 Subject: [PATCH 010/161] (remove) :checkered_flag: Windows day :checkered_flag: --- lib/github-package.js | 83 ++++++++++++---------- lib/models/workdir-context.js | 14 ++++ test/github-package.test.js | 89 ++++++++++++++++++++++-- test/models/workdir-context-pool.test.js | 37 ++++++++-- 4 files changed, 174 insertions(+), 49 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index de48a106e9..ab59897f22 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -83,8 +83,6 @@ export default class GithubPackage { this.workspace.addOpener(IssueishPaneItem.opener), ); - this.deserialize(this.savedState); - this.rerender(); } @@ -106,12 +104,6 @@ export default class GithubPackage { }; } - async deserialize(state) { - if (state.activeRepositoryPath) { - await this.didChangeProjectPaths([state.activeRepositoryPath]); - } - } - @autobind rerender() { if (!this.element) { @@ -207,44 +199,61 @@ export default class GithubPackage { return this.activeResolutionProgress; } - async scheduleActiveModelUpdate() { - await this.activeModelQueue.push(this.updateActiveModels, {parallel: false}); + async scheduleActiveModelUpdate(isActivating = false) { + await this.activeModelQueue.push(this.updateActiveModels.bind(this, isActivating), {parallel: false}); } - @autobind - async updateActiveModels() { - let nextActiveRepository = null; - let nextActiveResolutionProgress = null; - let nextActiveProjectPath = null; + async updateActiveModels(isActivating = false) { + let foundActiveModels = false; + + let nextActiveRepository = this.activeRepository; + let nextActiveResolutionProgress = this.activeResolutionProgress; + let nextActiveProjectPath = this.activeProjectPath; + + const setByProjectPath = async projectPath => { + if (!projectPath) { + return; + } + + nextActiveProjectPath = projectPath; + nextActiveRepository = await this.getRepositoryForWorkdirPath(nextActiveProjectPath); + nextActiveResolutionProgress = await this.getResolutionProgressForWorkdirPath(nextActiveProjectPath); + + foundActiveModels = true; + }; const activeItem = this.workspace.getActivePaneItem(); - if (activeItem && typeof activeItem.getPath === 'function') { - const projectPath = this.projectPathForItemPath(activeItem.getPath()); - if (projectPath) { - nextActiveProjectPath = projectPath; - await Promise.all([ - this.getRepositoryForWorkdirPath(projectPath).then(r => { nextActiveRepository = r; }), - this.getResolutionProgressForWorkdirPath(projectPath).then(rp => { nextActiveResolutionProgress = rp; }), - ]); + + if (activeItem) { + if (typeof activeItem.getRepository === 'function') { + // FilePatchController or other GitHub package pane + const repository = activeItem.getRepository(); + if (!repository.isDestroyed()) { + nextActiveRepository = repository; + nextActiveResolutionProgress = this.props.activeResolutionProgress; + nextActiveProjectPath = this.props.activeProjectPath; + + foundActiveModels = true; + } + + } else if (typeof activeItem.getPath === 'function') { + // TextEditor-like + const projectPath = this.projectPathForItemPath(activeItem.getPath()); + await setByProjectPath(projectPath); } - } else { - nextActiveRepository = this.activeRepository; - nextActiveResolutionProgress = this.activeResolutionProgress; - nextActiveProjectPath = this.activeProjectPath; } - if (activeItem instanceof FilePatchController) { - if (!activeItem.getRepository().isDestroyed()) { - nextActiveRepository = activeItem.props.repository; - nextActiveResolutionProgress = activeItem.props.resolutionProgress; - nextActiveProjectPath = nextActiveRepository.getWorkingDirectoryPath(); - } + if (!foundActiveModels && this.project.getPaths().length === 1) { + // Single project + await setByProjectPath(this.project.getPaths()[0]); } - if (!activeItem && this.project.getPaths().length === 1) { - nextActiveProjectPath = this.project.getPaths()[0]; - nextActiveRepository = await this.getRepositoryForWorkdirPath(nextActiveProjectPath); - nextActiveResolutionProgress = await this.getResolutionProgressForWorkdirPath(nextActiveProjectPath); + if (!foundActiveModels && isActivating) { + // Restore models from saved state + if (this.savedState.activeRepositoryPath) { + const projectPath = this.projectPathForItemPath(this.savedState.activeRepositoryPath); + await setByProjectPath(projectPath); + } } this.setActiveModels(nextActiveProjectPath, nextActiveRepository, nextActiveResolutionProgress); diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index 70d8b42f95..0436e46c44 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -113,6 +113,10 @@ export default class WorkdirContext { ]); } + isPresent() { + return true; + } + isDestroyed() { return this.destroyed; } @@ -170,3 +174,13 @@ export default class WorkdirContext { } } } + +class NullWorkdirContext extends WorkdirContext { + constructor() { + super(null, {}); + + this.repositoryHolder.setValue(null); + this.changeObserverHolder.setValue(null); + this.resolutionProgressHolder.setValue(null); + } +} diff --git a/test/github-package.test.js b/test/github-package.test.js index 3d5e61114b..0d8db5662d 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -64,17 +64,94 @@ describe('GithubPackage', function() { } describe('activate()', function() { - it('updates the active repository', async function() { + it('uses models from preexisting projects', async function() { + const workdirPath1 = await cloneRepository('three-files'); + const workdirPath2 = await cloneRepository('three-files'); + project.setPaths([workdirPath1, workdirPath2]); + + await githubPackage.activate(); + + await assert.async.isNotNull(await githubPackage.getRepositoryForWorkdirPath(workdirPath1)); + await assert.async.isNotNull(await githubPackage.getRepositoryForWorkdirPath(workdirPath2)); + assert.isNull(githubPackage.getActiveRepository()); + }); + + it('uses an active model from a single preexisting project', async function() { + const workdirPath = await cloneRepository('three-files'); + project.setPaths([workdirPath]); + await githubPackage.activate(); + + await assert.async.isNotNull(githubPackage.getActiveRepository()); + assert.equal( + await githubPackage.getActiveRepository().getWorkingDirectoryPath(), + workdirPath, + ); + }); + + it('uses an active model from a preexisting active pane item', async function() { const workdirPath1 = await cloneRepository('three-files'); const workdirPath2 = await cloneRepository('three-files'); project.setPaths([workdirPath1, workdirPath2]); - fs.writeFileSync(path.join(workdirPath1, 'a.txt'), 'change 1', 'utf8'); - fs.writeFileSync(path.join(workdirPath1, 'b.txt'), 'change 2', 'utf8'); + await workspace.open(path.join(workdirPath2, 'a.txt')); - await workspace.open(path.join(workdirPath1, 'a.txt')); - const repository = await githubPackage.getRepositoryForWorkdirPath(workdirPath1); - await until(() => githubPackage.getActiveRepository() === repository); + await githubPackage.activate(); + + await assert.async.isNotNull(githubPackage.getActiveRepository()); + assert.equal( + await githubPackage.getActiveRepository().getWorkingDirectoryPath(), + workdirPath2, + ); + }); + + it('uses an active model from serialized state', async function() { + const workdirPath1 = await cloneRepository('three-files'); + const workdirPath2 = await cloneRepository('three-files'); + const workdirPath3 = await cloneRepository('three-files'); + project.setPaths([workdirPath1, workdirPath2, workdirPath3]); + + await githubPackage.activate({ + activeRepositoryPath: workdirPath2, + }); + + await assert.async.isNotNull(githubPackage.getActiveRepository()); + assert.equal( + await githubPackage.getActiveRepository().getWorkingDirectoryPath(), + workdirPath2, + ); + }); + + it('prefers the active model from an active pane item to serialized state', async function() { + const workdirPath1 = await cloneRepository('three-files'); + const workdirPath2 = await cloneRepository('three-files'); + project.setPaths([workdirPath1, workdirPath2]); + await workspace.open(path.join(workdirPath2, 'b.txt')); + + await githubPackage.activate({ + activeRepositoryPath: workdirPath1, + }); + + await assert.async.isNotNull(githubPackage.getActiveRepository()); + assert.equal( + await githubPackage.getActiveRepository().getWorkingDirectoryPath(), + workdirPath2, + ); + }); + + it('prefers the active model from a single project to serialized state', async function() { + const workdirPath1 = await cloneRepository('three-files'); + const workdirPath2 = await cloneRepository('three-files'); + project.setPaths([workdirPath1]); + + await githubPackage.activate({ + activeRepositoryPath: workdirPath2, + }); + + await assert.async.isNotNull(githubPackage.getActiveRepository()); + assert.equal( + await githubPackage.getActiveRepository().getWorkingDirectoryPath(), + workdirPath1, + ); }); }); diff --git a/test/models/workdir-context-pool.test.js b/test/models/workdir-context-pool.test.js index 2ea6ceb6b2..cb2757b2d3 100644 --- a/test/models/workdir-context-pool.test.js +++ b/test/models/workdir-context-pool.test.js @@ -3,7 +3,7 @@ import {cloneRepository} from '../helpers'; import Repository from '../../lib/models/repository'; import WorkdirContextPool from '../../lib/models/workdir-context-pool'; -describe('WorkdirContextPool', function() { +describe.only('WorkdirContextPool', function() { let atomEnv, workspace, pool; beforeEach(function() { @@ -28,12 +28,12 @@ describe('WorkdirContextPool', function() { it('adds a WorkdirContext for a new working directory', function() { assert.equal(pool.size(), 0); - assert.isUndefined(pool.getContext(workingDirectory)); + assert.isFalse(pool.getContext(workingDirectory).isPresent()); pool.add(workingDirectory); assert.equal(pool.size(), 1); - assert.isDefined(pool.getContext(workingDirectory)); + assert.isTrue(pool.getContext(workingDirectory).isPresent()); }); it('optionally provides a preinitialized repository', async function() { @@ -50,7 +50,7 @@ describe('WorkdirContextPool', function() { assert.equal(pool.size(), 1); const context = pool.getContext(workingDirectory); - assert.isDefined(context); + assert.isTrue(context.isPresent()); pool.add(workingDirectory); assert.equal(pool.size(), 1); @@ -79,7 +79,7 @@ describe('WorkdirContextPool', function() { it('removes a WorkdirContext for an existing working directory', function() { assert.equal(pool.size(), 1); pool.remove(existingDirectory); - assert.isUndefined(pool.getContext(existingDirectory)); + assert.isFalse(pool.getContext(existingDirectory).isPresent()); assert.equal(pool.size(), 0); }); @@ -122,9 +122,34 @@ describe('WorkdirContextPool', function() { assert.equal(pool.size(), 2); + assert.isFalse(pool.getContext(dir0).isDefined()); + assert.isTrue(pool.getContext(dir1).isDefined()); + assert.isTrue(pool.getContext(dir2).isDefined()); + assert.isTrue(context0.isDestroyed()); assert.isFalse(context1.isDestroyed()); - assert.isDefined(pool.getContext(dir2)); + }); + }); + + describe('getContext', function() { + let dir; + + beforeEach(async function() { + dir = await cloneRepository('three-files'); + pool.add(dir); + }); + + it('returns a context by directory', async function() { + const context = pool.getContext(dir); + assert.isTrue(context.isPresent()); + + const repo = await context.getRepositoryPromise(); + assert.strictEqual(dir, repo.getWorkingDirectoryPath()); + }); + + it('returns a null context when missing', function() { + const context = pool.getContext(dir); + assert.False(context.isPresent()); }); }); }); From 129d5e7eb469042463d547a33167403a05a43902 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 7 Apr 2017 07:52:30 -0400 Subject: [PATCH 011/161] Return a null object from the pool when a directory is not present --- lib/github-package.js | 83 ++++++++++++---------- lib/models/workdir-context-pool.js | 4 +- lib/models/workdir-context.js | 28 ++++++++ test/github-package.test.js | 89 ++++++++++++++++++++++-- test/models/workdir-context-pool.test.js | 35 ++++++++-- 5 files changed, 189 insertions(+), 50 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index de48a106e9..ab59897f22 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -83,8 +83,6 @@ export default class GithubPackage { this.workspace.addOpener(IssueishPaneItem.opener), ); - this.deserialize(this.savedState); - this.rerender(); } @@ -106,12 +104,6 @@ export default class GithubPackage { }; } - async deserialize(state) { - if (state.activeRepositoryPath) { - await this.didChangeProjectPaths([state.activeRepositoryPath]); - } - } - @autobind rerender() { if (!this.element) { @@ -207,44 +199,61 @@ export default class GithubPackage { return this.activeResolutionProgress; } - async scheduleActiveModelUpdate() { - await this.activeModelQueue.push(this.updateActiveModels, {parallel: false}); + async scheduleActiveModelUpdate(isActivating = false) { + await this.activeModelQueue.push(this.updateActiveModels.bind(this, isActivating), {parallel: false}); } - @autobind - async updateActiveModels() { - let nextActiveRepository = null; - let nextActiveResolutionProgress = null; - let nextActiveProjectPath = null; + async updateActiveModels(isActivating = false) { + let foundActiveModels = false; + + let nextActiveRepository = this.activeRepository; + let nextActiveResolutionProgress = this.activeResolutionProgress; + let nextActiveProjectPath = this.activeProjectPath; + + const setByProjectPath = async projectPath => { + if (!projectPath) { + return; + } + + nextActiveProjectPath = projectPath; + nextActiveRepository = await this.getRepositoryForWorkdirPath(nextActiveProjectPath); + nextActiveResolutionProgress = await this.getResolutionProgressForWorkdirPath(nextActiveProjectPath); + + foundActiveModels = true; + }; const activeItem = this.workspace.getActivePaneItem(); - if (activeItem && typeof activeItem.getPath === 'function') { - const projectPath = this.projectPathForItemPath(activeItem.getPath()); - if (projectPath) { - nextActiveProjectPath = projectPath; - await Promise.all([ - this.getRepositoryForWorkdirPath(projectPath).then(r => { nextActiveRepository = r; }), - this.getResolutionProgressForWorkdirPath(projectPath).then(rp => { nextActiveResolutionProgress = rp; }), - ]); + + if (activeItem) { + if (typeof activeItem.getRepository === 'function') { + // FilePatchController or other GitHub package pane + const repository = activeItem.getRepository(); + if (!repository.isDestroyed()) { + nextActiveRepository = repository; + nextActiveResolutionProgress = this.props.activeResolutionProgress; + nextActiveProjectPath = this.props.activeProjectPath; + + foundActiveModels = true; + } + + } else if (typeof activeItem.getPath === 'function') { + // TextEditor-like + const projectPath = this.projectPathForItemPath(activeItem.getPath()); + await setByProjectPath(projectPath); } - } else { - nextActiveRepository = this.activeRepository; - nextActiveResolutionProgress = this.activeResolutionProgress; - nextActiveProjectPath = this.activeProjectPath; } - if (activeItem instanceof FilePatchController) { - if (!activeItem.getRepository().isDestroyed()) { - nextActiveRepository = activeItem.props.repository; - nextActiveResolutionProgress = activeItem.props.resolutionProgress; - nextActiveProjectPath = nextActiveRepository.getWorkingDirectoryPath(); - } + if (!foundActiveModels && this.project.getPaths().length === 1) { + // Single project + await setByProjectPath(this.project.getPaths()[0]); } - if (!activeItem && this.project.getPaths().length === 1) { - nextActiveProjectPath = this.project.getPaths()[0]; - nextActiveRepository = await this.getRepositoryForWorkdirPath(nextActiveProjectPath); - nextActiveResolutionProgress = await this.getResolutionProgressForWorkdirPath(nextActiveProjectPath); + if (!foundActiveModels && isActivating) { + // Restore models from saved state + if (this.savedState.activeRepositoryPath) { + const projectPath = this.projectPathForItemPath(this.savedState.activeRepositoryPath); + await setByProjectPath(projectPath); + } } this.setActiveModels(nextActiveProjectPath, nextActiveRepository, nextActiveResolutionProgress); diff --git a/lib/models/workdir-context-pool.js b/lib/models/workdir-context-pool.js index af32e19d1f..204361b305 100644 --- a/lib/models/workdir-context-pool.js +++ b/lib/models/workdir-context-pool.js @@ -1,6 +1,6 @@ import compareSets from 'compare-sets'; -import WorkdirContext from './workdir-context'; +import WorkdirContext, {nullWorkdirContext} from './workdir-context'; /** * Manage a WorkdirContext for each open directory. @@ -24,7 +24,7 @@ export default class WorkdirContextPool { * Access the context mapped to a known directory. */ getContext(directory) { - return this.contexts.get(directory); + return this.contexts.get(directory) || nullWorkdirContext; } add(directory, options = {}) { diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index 70d8b42f95..8ba510c41f 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -113,6 +113,10 @@ export default class WorkdirContext { ]); } + isPresent() { + return true; + } + isDestroyed() { return this.destroyed; } @@ -170,3 +174,27 @@ export default class WorkdirContext { } } } + +class NullWorkdirContext extends WorkdirContext { + constructor() { + super(null, {}); + + this.repositoryHolder.setValue(null); + this.changeObserverHolder.setValue(null); + this.resolutionProgressHolder.setValue(null); + } + + create() { + return Promise.resolve(); + } + + isPresent() { + return false; + } + + destroy() { + return Promise.resolve(); + } +} + +export const nullWorkdirContext = new NullWorkdirContext(); diff --git a/test/github-package.test.js b/test/github-package.test.js index 3d5e61114b..0d8db5662d 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -64,17 +64,94 @@ describe('GithubPackage', function() { } describe('activate()', function() { - it('updates the active repository', async function() { + it('uses models from preexisting projects', async function() { + const workdirPath1 = await cloneRepository('three-files'); + const workdirPath2 = await cloneRepository('three-files'); + project.setPaths([workdirPath1, workdirPath2]); + + await githubPackage.activate(); + + await assert.async.isNotNull(await githubPackage.getRepositoryForWorkdirPath(workdirPath1)); + await assert.async.isNotNull(await githubPackage.getRepositoryForWorkdirPath(workdirPath2)); + assert.isNull(githubPackage.getActiveRepository()); + }); + + it('uses an active model from a single preexisting project', async function() { + const workdirPath = await cloneRepository('three-files'); + project.setPaths([workdirPath]); + await githubPackage.activate(); + + await assert.async.isNotNull(githubPackage.getActiveRepository()); + assert.equal( + await githubPackage.getActiveRepository().getWorkingDirectoryPath(), + workdirPath, + ); + }); + + it('uses an active model from a preexisting active pane item', async function() { const workdirPath1 = await cloneRepository('three-files'); const workdirPath2 = await cloneRepository('three-files'); project.setPaths([workdirPath1, workdirPath2]); - fs.writeFileSync(path.join(workdirPath1, 'a.txt'), 'change 1', 'utf8'); - fs.writeFileSync(path.join(workdirPath1, 'b.txt'), 'change 2', 'utf8'); + await workspace.open(path.join(workdirPath2, 'a.txt')); - await workspace.open(path.join(workdirPath1, 'a.txt')); - const repository = await githubPackage.getRepositoryForWorkdirPath(workdirPath1); - await until(() => githubPackage.getActiveRepository() === repository); + await githubPackage.activate(); + + await assert.async.isNotNull(githubPackage.getActiveRepository()); + assert.equal( + await githubPackage.getActiveRepository().getWorkingDirectoryPath(), + workdirPath2, + ); + }); + + it('uses an active model from serialized state', async function() { + const workdirPath1 = await cloneRepository('three-files'); + const workdirPath2 = await cloneRepository('three-files'); + const workdirPath3 = await cloneRepository('three-files'); + project.setPaths([workdirPath1, workdirPath2, workdirPath3]); + + await githubPackage.activate({ + activeRepositoryPath: workdirPath2, + }); + + await assert.async.isNotNull(githubPackage.getActiveRepository()); + assert.equal( + await githubPackage.getActiveRepository().getWorkingDirectoryPath(), + workdirPath2, + ); + }); + + it('prefers the active model from an active pane item to serialized state', async function() { + const workdirPath1 = await cloneRepository('three-files'); + const workdirPath2 = await cloneRepository('three-files'); + project.setPaths([workdirPath1, workdirPath2]); + await workspace.open(path.join(workdirPath2, 'b.txt')); + + await githubPackage.activate({ + activeRepositoryPath: workdirPath1, + }); + + await assert.async.isNotNull(githubPackage.getActiveRepository()); + assert.equal( + await githubPackage.getActiveRepository().getWorkingDirectoryPath(), + workdirPath2, + ); + }); + + it('prefers the active model from a single project to serialized state', async function() { + const workdirPath1 = await cloneRepository('three-files'); + const workdirPath2 = await cloneRepository('three-files'); + project.setPaths([workdirPath1]); + + await githubPackage.activate({ + activeRepositoryPath: workdirPath2, + }); + + await assert.async.isNotNull(githubPackage.getActiveRepository()); + assert.equal( + await githubPackage.getActiveRepository().getWorkingDirectoryPath(), + workdirPath1, + ); }); }); diff --git a/test/models/workdir-context-pool.test.js b/test/models/workdir-context-pool.test.js index 2ea6ceb6b2..dcdda06224 100644 --- a/test/models/workdir-context-pool.test.js +++ b/test/models/workdir-context-pool.test.js @@ -28,12 +28,12 @@ describe('WorkdirContextPool', function() { it('adds a WorkdirContext for a new working directory', function() { assert.equal(pool.size(), 0); - assert.isUndefined(pool.getContext(workingDirectory)); + assert.isFalse(pool.getContext(workingDirectory).isPresent()); pool.add(workingDirectory); assert.equal(pool.size(), 1); - assert.isDefined(pool.getContext(workingDirectory)); + assert.isTrue(pool.getContext(workingDirectory).isPresent()); }); it('optionally provides a preinitialized repository', async function() { @@ -50,7 +50,7 @@ describe('WorkdirContextPool', function() { assert.equal(pool.size(), 1); const context = pool.getContext(workingDirectory); - assert.isDefined(context); + assert.isTrue(context.isPresent()); pool.add(workingDirectory); assert.equal(pool.size(), 1); @@ -79,7 +79,7 @@ describe('WorkdirContextPool', function() { it('removes a WorkdirContext for an existing working directory', function() { assert.equal(pool.size(), 1); pool.remove(existingDirectory); - assert.isUndefined(pool.getContext(existingDirectory)); + assert.isFalse(pool.getContext(existingDirectory).isPresent()); assert.equal(pool.size(), 0); }); @@ -122,9 +122,34 @@ describe('WorkdirContextPool', function() { assert.equal(pool.size(), 2); + assert.isFalse(pool.getContext(dir0).isPresent()); + assert.isTrue(pool.getContext(dir1).isPresent()); + assert.isTrue(pool.getContext(dir2).isPresent()); + assert.isTrue(context0.isDestroyed()); assert.isFalse(context1.isDestroyed()); - assert.isDefined(pool.getContext(dir2)); + }); + }); + + describe('getContext', function() { + let dir; + + beforeEach(async function() { + dir = await cloneRepository('three-files'); + pool.add(dir); + }); + + it('returns a context by directory', async function() { + const context = pool.getContext(dir); + assert.isTrue(context.isPresent()); + + const repo = await context.getRepositoryPromise(); + assert.strictEqual(dir, repo.getWorkingDirectoryPath()); + }); + + it('returns a null context when missing', function() { + const context = pool.getContext('/nope'); + assert.isFalse(context.isPresent()); }); }); }); From 785386acb6c197f6b50e1b5ab288d09a85c7f119 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 7 Apr 2017 10:34:55 -0400 Subject: [PATCH 012/161] Pass a prompt callback to created contexts --- lib/models/workdir-context.js | 4 ++++ test/models/workdir-context.test.js | 27 ++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index 8ba510c41f..94aa6c0761 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -92,6 +92,10 @@ export default class WorkdirContext { repo = this.getRepository(); } + if (this.options.promptCallback) { + repo.setPromptCallback(this.options.promptCallback); + } + const changeObserver = process.platform === 'linux' ? new WorkspaceChangeObserver(this.options.window, this.options.workspace, repo) : new FileSystemChangeObserver(repo); diff --git a/test/models/workdir-context.test.js b/test/models/workdir-context.test.js index 9586266427..f51d34e8a8 100644 --- a/test/models/workdir-context.test.js +++ b/test/models/workdir-context.test.js @@ -7,10 +7,22 @@ import Repository from '../../lib/models/repository'; describe('WorkdirContext', function() { let context, workingDirectory, subs; + let mockWindow, mockWorkspace, mockPromptCallback; beforeEach(async function() { workingDirectory = await cloneRepository('three-files'); subs = new CompositeDisposable(); + + mockWindow = { + addEventListener: sinon.spy(), + removeEventListener: sinon.spy(), + }; + + mockWorkspace = { + observeTextEditors: sinon.spy(), + }; + + mockPromptCallback = query => 'reply'; }); afterEach(async function() { @@ -20,7 +32,11 @@ describe('WorkdirContext', function() { describe('without an initial Repository', function() { beforeEach(function() { - context = new WorkdirContext(workingDirectory); + context = new WorkdirContext(workingDirectory, { + window: mockWindow, + workspace: mockWorkspace, + promptCallback: mockPromptCallback, + }); }); it('returns null for each synchronous model', function() { @@ -60,6 +76,15 @@ describe('WorkdirContext', function() { assert.strictEqual(observer, await observerPromise); }); + it('configures the repository with a prompt callback', async function() { + await context.create(); + + const repo = context.getRepository(); + for (const strategy of repo.git.getImplementers()) { + assert.strictEqual(strategy.prompt, mockPromptCallback); + } + }); + it('refreshes the repository on any filesystem change', async function() { await context.create(); From f71a932adf2f5b2cd702335957087d629535931d Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 7 Apr 2017 11:00:01 -0400 Subject: [PATCH 013/161] Restore saved resolution progress state --- lib/models/workdir-context-pool.js | 14 +++++++-- test/models/workdir-context-pool.test.js | 38 +++++++++++++++++++----- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/lib/models/workdir-context-pool.js b/lib/models/workdir-context-pool.js index 204361b305..d42db2b130 100644 --- a/lib/models/workdir-context-pool.js +++ b/lib/models/workdir-context-pool.js @@ -8,9 +8,17 @@ import WorkdirContext, {nullWorkdirContext} from './workdir-context'; export default class WorkdirContextPool { /** - * Options will be passed to each `WorkdirContext` as it is created. + * Recognized options: + * + * - `options.resolutionProgressByPath`: saved resolution progress state associated with a previously opened + * working directory. + * + * All other options will be passed to each `WorkdirContext` as it is created. */ constructor(options = {}) { + this.resolutionProgressByPath = options.resolutionProgressByPath || {}; + delete options.resolutionProgressByPath; + this.options = options; this.contexts = new Map(); @@ -32,7 +40,9 @@ export default class WorkdirContextPool { return; } - const context = new WorkdirContext(directory, {...this.options, ...options}); + const savedResolutionProgress = this.resolutionProgressByPath[directory] || {}; + + const context = new WorkdirContext(directory, {savedResolutionProgress, ...this.options, ...options}); this.contexts.set(directory, context); context.create(); diff --git a/test/models/workdir-context-pool.test.js b/test/models/workdir-context-pool.test.js index dcdda06224..a3effda0bd 100644 --- a/test/models/workdir-context-pool.test.js +++ b/test/models/workdir-context-pool.test.js @@ -4,21 +4,28 @@ import Repository from '../../lib/models/repository'; import WorkdirContextPool from '../../lib/models/workdir-context-pool'; describe('WorkdirContextPool', function() { - let atomEnv, workspace, pool; + let pool; + let mockWindow, mockWorkspace, mockResolutionProgressState; beforeEach(function() { - atomEnv = global.buildAtomEnvironment(); - workspace = atomEnv.workspace; + mockWindow = { + addEventListener: sinon.spy(), + removeEventListener: sinon.spy(), + }; + + mockWorkspace = { + observeTextEditors: sinon.spy(), + }; + + mockResolutionProgressState = {}; pool = new WorkdirContextPool({ - window, workspace, + window: mockWindow, + workspace: mockWorkspace, + resolutionProgressByPath: mockResolutionProgressState, }); }); - afterEach(function() { - atomEnv.destroy(); - }); - describe('add', function() { let workingDirectory; @@ -64,6 +71,19 @@ describe('WorkdirContextPool', function() { const repo = await context.getRepositoryPromise(); assert.isNotNull(repo); }); + + it('passes appropriate serialized state to the resolution progress', async function() { + mockResolutionProgressState[workingDirectory] = { + revision: '66d11860af6d28eb38349ef83de475597cb0e8b4', + paths: {'a.txt': 12}, + }; + + pool.add(workingDirectory); + + const resolutionProgress = await pool.getContext(workingDirectory).getResolutionProgressPromise(); + assert.isNotNull(resolutionProgress); + assert.equal(resolutionProgress.getRemaining('a.txt'), 12); + }); }); describe('remove', function() { @@ -129,6 +149,8 @@ describe('WorkdirContextPool', function() { assert.isTrue(context0.isDestroyed()); assert.isFalse(context1.isDestroyed()); }); + + it('passes appropriate serialized state to any created resolution progresses'); }); describe('getContext', function() { From fe59ecdcb0334499cf387c9140ae12ce5f4810fb Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 7 Apr 2017 11:09:58 -0400 Subject: [PATCH 014/161] Change observers get a synchronous Repository --- lib/models/file-system-change-observer.js | 13 +++++----- lib/models/workspace-change-observer.js | 30 +++++++++++------------ 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/lib/models/file-system-change-observer.js b/lib/models/file-system-change-observer.js index 1187dffb53..e6922e5396 100644 --- a/lib/models/file-system-change-observer.js +++ b/lib/models/file-system-change-observer.js @@ -6,9 +6,9 @@ import path from 'path'; import EventLogger from './event-logger'; export default class FileSystemChangeObserver { - constructor(repositoryPromise) { + constructor(repository) { this.emitter = new Emitter(); - this.repositoryPromise = repositoryPromise; + this.repository = repository; this.logger = new EventLogger('FileSystemChangeObserver'); } @@ -37,10 +37,9 @@ export default class FileSystemChangeObserver { } async watchRepository() { - const repository = await this.getRepository(); - if (repository) { + if (this.repository) { this.currentFileWatcher = await nsfw( - repository.getWorkingDirectoryPath(), + this.repository.getWorkingDirectoryPath(), events => { const isNonGitFile = event => !event.directory.split(path.sep).includes('.git') && event.file !== '.git'; const isWatchedGitFile = event => { @@ -61,7 +60,7 @@ export default class FileSystemChangeObserver { { debounceMS: 100, errorCallback: errors => { - const workingDirectory = repository.getWorkingDirectoryPath(); + const workingDirectory = this.repository.getWorkingDirectoryPath(); // eslint-disable-next-line no-console console.warn(`Error in FileSystemChangeObserver in ${workingDirectory}:`, errors); this.stopCurrentFileWatcher(); @@ -69,7 +68,7 @@ export default class FileSystemChangeObserver { }, ); await this.currentFileWatcher.start(); - this.logger.showStarted(repository.getWorkingDirectoryPath()); + this.logger.showStarted(this.repository.getWorkingDirectoryPath()); } } diff --git a/lib/models/workspace-change-observer.js b/lib/models/workspace-change-observer.js index 18755edfbd..0f99f70f2c 100644 --- a/lib/models/workspace-change-observer.js +++ b/lib/models/workspace-change-observer.js @@ -6,9 +6,9 @@ import {autobind} from 'core-decorators'; import EventLogger from './event-logger'; export default class WorkspaceChangeObserver { - constructor(window, workspace, repositoryPromise) { + constructor(window, workspace, repository) { this.window = window; - this.repositoryPromise = repositoryPromise; + this.repository = repository; this.workspace = workspace; this.observedBuffers = new WeakSet(); this.emitter = new Emitter(); @@ -17,8 +17,8 @@ export default class WorkspaceChangeObserver { async start() { this.started = true; - const focusHandler = async event => { - if (await this.repositoryPromise) { + const focusHandler = event => { + if (this.repository) { this.logger.showEvents([event]); this.emitter.emit('did-change'); } @@ -50,12 +50,12 @@ export default class WorkspaceChangeObserver { } getRepository() { - return this.repositoryPromise; + return this.repository; } async watchActiveRepositoryGitDirectory() { - const repository = await this.getRepository(); - const gitDirectoryPath = await repository.getGitDirectoryPath(); + const repository = this.getRepository(); + const gitDirectoryPath = repository.getGitDirectoryPath(); if (repository) { this.currentFileWatcher = await nsfw( gitDirectoryPath, @@ -96,8 +96,8 @@ export default class WorkspaceChangeObserver { } } - async activeRepositoryContainsPath(path) { - const repository = await this.getRepository(); + activeRepositoryContainsPath(path) { + const repository = this.getRepository(); if (repository) { return path.indexOf(repository.getWorkingDirectoryPath()) !== -1; } else { @@ -111,8 +111,8 @@ export default class WorkspaceChangeObserver { if (!this.observedBuffers.has(buffer)) { this.observedBuffers.add(buffer); const disposables = new CompositeDisposable( - buffer.onDidSave(async () => { - if (await this.activeRepositoryContainsPath(buffer.getPath())) { + buffer.onDidSave(() => { + if (this.activeRepositoryContainsPath(buffer.getPath())) { this.logger.showEvents([{ handler: 'onDidSave', path: buffer.getPath(), @@ -120,8 +120,8 @@ export default class WorkspaceChangeObserver { this.emitter.emit('did-change'); } }), - buffer.onDidReload(async () => { - if (await this.activeRepositoryContainsPath(buffer.getPath())) { + buffer.onDidReload(() => { + if (this.activeRepositoryContainsPath(buffer.getPath())) { this.logger.showEvents([{ handler: 'onDidReload', path: buffer.getPath(), @@ -129,8 +129,8 @@ export default class WorkspaceChangeObserver { this.emitter.emit('did-change'); } }), - buffer.onDidDestroy(async () => { - if (await this.activeRepositoryContainsPath(buffer.getPath())) { + buffer.onDidDestroy(() => { + if (this.activeRepositoryContainsPath(buffer.getPath())) { this.logger.showEvents([{ handler: 'onDidDestroy', path: buffer.getPath(), From 696585f51619ca5502b51000d60a52d4279758f0 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 7 Apr 2017 14:23:22 -0400 Subject: [PATCH 015/161] Missed one --- lib/models/file-system-change-observer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/file-system-change-observer.js b/lib/models/file-system-change-observer.js index e6922e5396..c18046ce56 100644 --- a/lib/models/file-system-change-observer.js +++ b/lib/models/file-system-change-observer.js @@ -33,7 +33,7 @@ export default class FileSystemChangeObserver { } getRepository() { - return this.repositoryPromise; + return this.repository; } async watchRepository() { From 27401318a728315a41f34a00df8601c108f515ca Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 7 Apr 2017 14:24:01 -0400 Subject: [PATCH 016/161] Cache working directory lookups --- lib/models/workdir-cache.js | 83 +++++++++++++++++++++++++++++++ test/models/workdir-cache.test.js | 76 ++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 lib/models/workdir-cache.js create mode 100644 test/models/workdir-cache.test.js diff --git a/lib/models/workdir-cache.js b/lib/models/workdir-cache.js new file mode 100644 index 0000000000..37e924ef67 --- /dev/null +++ b/lib/models/workdir-cache.js @@ -0,0 +1,83 @@ +import path from 'path'; +import fs from 'fs'; + +/** + * Locate the nearest git working directory above a given starting point, caching results. + */ +export default class WorkdirCache { + constructor(maxSize = 1000) { + this.maxSize = maxSize; + this.known = new Map(); + } + + async find(startDir) { + try { + const resolvedDir = await this.resolvePath(startDir); + const cached = this.known.get(resolvedDir); + if (cached !== undefined) { + return cached; + } + + const workDir = await this.walkToRoot(resolvedDir); + + if (this.known.size >= this.maxSize) { + this.known.clear(); + } + this.known.set(resolvedDir, workDir); + return workDir; + } catch (e) { + if (e.code === 'ENOENT') { + return null; + } + + throw e; + } + } + + resolvePath(unresolvedPath) { + return new Promise((resolve, reject) => { + fs.realpath(unresolvedPath, (err, resolved) => (err ? reject(err) : resolve(resolved))); + }); + } + + walkToRoot(initialDir) { + return new Promise((resolve, reject) => { + let currentDir = initialDir; + + const check = () => { + const dotGit = path.join(currentDir, '.git'); + fs.stat(dotGit, (statError, stat) => { + if (statError) { + if (statError.code === 'ENOENT') { + // File not found. This is not the directory we're looking for. Continue walking. + return walk(); + } + + reject(statError); + } + + if (!stat.isDirectory()) { + // A file called ".git". Probably not a good idea, but still not a git working directory. + return walk(); + } + + // .git directory found! Mission accomplished. + return resolve(currentDir); + }); + }; + + const walk = () => { + const parentDir = path.resolve(currentDir, '..'); + if (parentDir === currentDir) { + // Root directory. Traversal done, no working directory found. + resolve(null); + } + + currentDir = parentDir; + check(); + }; + + check(); + }); + } +} diff --git a/test/models/workdir-cache.test.js b/test/models/workdir-cache.test.js new file mode 100644 index 0000000000..8315f3fcaf --- /dev/null +++ b/test/models/workdir-cache.test.js @@ -0,0 +1,76 @@ +import path from 'path'; +import temp from 'temp'; + +import {cloneRepository} from '../helpers'; + +import WorkdirCache from '../../lib/models/workdir-cache'; + +describe('WorkdirCache', function() { + let cache; + + beforeEach(function() { + cache = new WorkdirCache(5); + }); + + it('finds a workdir that is the given path', async function() { + const sameDir = await cloneRepository('three-files'); + const workDir = await cache.find(sameDir); + assert.equal(sameDir, workDir); + }); + + it("finds a workdir that's a parent of the given path", async function() { + const expectedDir = await cloneRepository('three-files'); + const givenDir = path.join(expectedDir, 'subdir-1'); + const actualDir = await cache.find(givenDir); + + assert.equal(actualDir, expectedDir); + }); + + it('returns null when a path is not in a git repository', async function() { + const nonWorkdirPath = temp.mkdirSync(); + assert.isNull(await cache.find(nonWorkdirPath)); + }); + + it('returns null when a path does not exist', async function() { + const nope = path.join( + __dirname, + 'does', 'not', 'exist', 'no', 'seriously', 'why', 'did', 'you', 'name', 'a', 'directory', 'this', + ); + assert.isNull(await cache.find(nope)); + }); + + it('understands a file path', async function() { + const expectedDir = await cloneRepository('three-files'); + const givenFile = path.join(expectedDir, 'subdir-1', 'b.txt'); + const actualDir = await cache.find(givenFile); + + assert.equal(actualDir, expectedDir); + }); + + it('caches previously discovered results', async function() { + const expectedDir = await cloneRepository('three-files'); + const givenDir = path.join(expectedDir, 'subdir-1'); + + // Prime the cache + await cache.find(givenDir); + + sinon.spy(cache, 'walkToRoot'); + const actualDir = await cache.find(givenDir); + assert.equal(actualDir, expectedDir); + assert.isFalse(cache.walkToRoot.called); + }); + + it('clears the cache when the maximum size is exceeded', async function() { + const dirs = await Promise.all( + Array(6).fill(null, 0, 6).map(() => cloneRepository('three-files')), + ); + + await Promise.all(dirs.map(dir => cache.find(dir))); + const expectedDir = dirs[1]; + + sinon.spy(cache, 'walkToRoot'); + const actualDir = await cache.find(expectedDir); + assert.equal(actualDir, expectedDir); + assert.isTrue(cache.walkToRoot.called); + }); +}); From 0432866dccfa3b70305e3d83a9bd9ce7c0777a66 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 7 Apr 2017 16:08:58 -0400 Subject: [PATCH 017/161] Clear a pool --- lib/models/workdir-context-pool.js | 10 ++++++++++ lib/models/workdir-context.js | 4 ++++ test/models/workdir-context-pool.test.js | 23 +++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/lib/models/workdir-context-pool.js b/lib/models/workdir-context-pool.js index d42db2b130..1c1a4517cd 100644 --- a/lib/models/workdir-context-pool.js +++ b/lib/models/workdir-context-pool.js @@ -65,4 +65,14 @@ export default class WorkdirContextPool { this.remove(directory); } } + + withResidentContexts(callback) { + for (const [workdir, context] of this.contexts) { + callback(workdir, context); + } + } + + clear() { + this.withResidentContexts(workdir => this.remove(workdir)); + } } diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index 94aa6c0761..f4dbf05129 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -132,6 +132,10 @@ export default class WorkdirContext { return this.emitter.on('did-change-workdir-or-head', callback); } + getWorkingDirectory() { + return this.directory; + } + getRepository() { return this.repositoryHolder.getValue(); } diff --git a/test/models/workdir-context-pool.test.js b/test/models/workdir-context-pool.test.js index a3effda0bd..a447015c1f 100644 --- a/test/models/workdir-context-pool.test.js +++ b/test/models/workdir-context-pool.test.js @@ -174,4 +174,27 @@ describe('WorkdirContextPool', function() { assert.isFalse(context.isPresent()); }); }); + + describe('clear', function() { + it('removes all resident contexts', async function() { + const [dir0, dir1, dir2] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); + + pool.add(dir0); + pool.add(dir1); + pool.add(dir2); + + assert.equal(pool.size(), 3); + + pool.clear(); + + assert.equal(pool.size(), 0); + assert.isFalse(pool.getContext(dir0).isPresent()); + assert.isFalse(pool.getContext(dir1).isPresent()); + assert.isFalse(pool.getContext(dir2).isPresent()); + }); + }); }); From 7bb3a799ad062d0f9903986f5ece1737d29d42b8 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 7 Apr 2017 16:20:47 -0400 Subject: [PATCH 018/161] Missed an option in the docs --- lib/models/workdir-context.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index f4dbf05129..0e3d28f59f 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -60,6 +60,7 @@ export default class WorkdirContext { * - `options.savedResolutionProgress`: Serialized state used to initialize the ResolutionProgress. * - `options.window`: Browser window global, used on Linux by the WorkspaceChangeObserver. * - `options.workspace`: Atom's workspace singleton, used on Linux by the WorkspaceChangeObserver. + * - `options.promptCallback`: Callback used to collect information interactively through Atom. */ constructor(directory, options = {}) { this.directory = directory; From 1b883bd54e6fe0a33219dcdc755b96a32157843c Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 7 Apr 2017 16:27:14 -0400 Subject: [PATCH 019/161] Rework GitHubPackage to use the WorkdirContext objects --- lib/github-package.js | 322 +++++++++++++++--------------------------- 1 file changed, 116 insertions(+), 206 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 64a6f712ff..af4212578c 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -7,12 +7,14 @@ import ReactDom from 'react-dom'; import {autobind} from 'core-decorators'; import compareSets from 'compare-sets'; +import WorkdirCache from './models/workdir-cache'; +import {nullWorkdirContext} from './models/workdir-context'; +import WorkdirContextPool from './models/workdir-context-pool'; import Repository from './models/repository'; import FileSystemChangeObserver from './models/file-system-change-observer'; import WorkspaceChangeObserver from './models/workspace-change-observer'; import ResolutionProgress from './models/conflicts/resolution-progress'; import StyleCalculator from './models/style-calculator'; -import FilePatchController from './controllers/file-patch-controller'; import RootController from './controllers/root-controller'; import IssueishPaneItem from './atom-items/issueish-pane-item'; import Switchboard from './switchboard'; @@ -35,17 +37,18 @@ export default class GithubPackage { this.styles = styles; this.styleCalculator = new StyleCalculator(this.styles, this.config); - this.modelPromisesByProjectPath = new Map(); - this.modelsByProjectPath = new Map(); this.confirm = confirm; - this.activeModelQueue = new AsyncQueue(); - this.activeProjectPath = null; - this.activeRepository = null; - this.activeResolutionProgress = null; + this.activeContextQueue = new AsyncQueue(); + this.activeWorkdir = nullWorkdirContext; + this.workdirCache = new WorkdirCache(); + this.contextPool = new WorkdirContextPool({ + window, + workspace, + promptCallback: query => this.controller.promptForCredentials(query), + }); this.subscriptions = new CompositeDisposable(); - this.savedState = {}; this.switchboard = new Switchboard(); this.setupYardstick(); @@ -78,17 +81,11 @@ export default class GithubPackage { } activate(state = {}) { - this.savedState = {...defaultState, ...state}; - - this.subscriptions = new CompositeDisposable(); - const projectPaths = this.project.getDirectories().map(dir => dir.getRealPathSync()); - if (projectPaths.length > 0) { - this.didChangeProjectPaths(projectPaths); - } + const savedState = {...defaultState, ...state}; this.subscriptions.add( - this.project.onDidChangePaths(this.didChangeProjectPaths), - this.workspace.onDidChangeActivePaneItem(this.didChangeActivePaneItem), + this.project.onDidChangePaths(this.scheduleActiveContextUpdate), + this.workspace.onDidChangeActivePaneItem(this.scheduleActiveContextUpdate), this.styleCalculator.startWatching( 'github-package-styles', ['editor.fontSize', 'editor.fontFamily', 'editor.lineHeight'], @@ -114,22 +111,26 @@ export default class GithubPackage { this.workspace.addOpener(IssueishPaneItem.opener), ); + this.scheduleActiveModelUpdate(savedState); this.rerender(); } serialize() { const activeRepository = this.getActiveRepository(); + const activeRepositoryPath = activeRepository ? activeRepository.getWorkingDirectoryPath() : null; const resolutionProgressByPath = {}; - this.modelsByProjectPath.forEach((models, projectPath) => { - const resolutionProgress = models.resolutionProgress; - if (!resolutionProgress.isEmpty()) { - resolutionProgressByPath[projectPath] = resolutionProgress.serialize(); + this.activeContext.withResidentContexts((workdir, context) => { + const resolutionProgress = context.getResolutionProgress(); + if (!resolutionProgress || resolutionProgress.isEmpty()) { + return; } + + resolutionProgressByPath[workdir] = resolutionProgress.serialize(); }); return { - activeRepositoryPath: activeRepository ? activeRepository.getWorkingDirectoryPath() : null, + activeRepositoryPath, gitController: this.controller.serialize(), resolutionProgressByPath, }; @@ -168,11 +169,13 @@ export default class GithubPackage { async deactivate() { this.subscriptions.dispose(); - if (this.destroyedRepositorySubscription) { this.destroyedRepositorySubscription.dispose(); } - await Promise.all([ - this.destroyModelsForPaths(Array.from(this.modelPromisesByProjectPath.keys())), - yardstick.flush(), - ]); + + this.destroyedRepositorySubscription && this.destroyedRepositorySubscription.dispose(); + this.changedRepositorySubscription && this.changedRepositorySubscription.dispose(); + + this.contextPool.clear(); + + await yardstick.flush(); } consumeStatusBar(statusBar) { @@ -188,17 +191,6 @@ export default class GithubPackage { return IssueishPaneItem.opener(uri); } - @autobind - async didChangeProjectPaths(projectPaths) { - const previousProjectPaths = Array.from(this.modelPromisesByProjectPath.keys()); - const {added, removed} = compareSets(new Set(previousProjectPaths), new Set(projectPaths)); - await Promise.all([ - this.cacheModelsForPaths(Array.from(added)), - this.destroyModelsForPaths(Array.from(removed)), - ]); - await this.scheduleActiveModelUpdate(); - } - @autobind async createRepositoryForProjectPath(projectPath) { if (!projectPath) { @@ -217,216 +209,134 @@ export default class GithubPackage { await this.scheduleActiveModelUpdate(); } - @autobind - async didChangeActivePaneItem() { - await this.scheduleActiveModelUpdate(); - } - - getActiveProjectPath() { - return this.activeProjectPath; + getActiveWorkdir() { + return this.activeContext.getWorkingDirectory(); } getActiveRepository() { - return this.activeRepository; + return this.activeContext.getRepository(); } getActiveResolutionProgress() { - return this.activeResolutionProgress; + return this.activeContext.getResolutionProgress(); } - async scheduleActiveModelUpdate(isActivating = false) { - await this.activeModelQueue.push(this.updateActiveModels.bind(this, isActivating), {parallel: false}); + @autobind + async scheduleActiveContextUpdate(savedState = {}) { + await this.activeContextQueue.push(this.updateActiveModels.bind(this, savedState), {parallel: false}); } - async updateActiveModels(isActivating = false) { - let foundActiveModels = false; - - let nextActiveRepository = this.activeRepository; - let nextActiveResolutionProgress = this.activeResolutionProgress; - let nextActiveProjectPath = this.activeProjectPath; - - const setByProjectPath = async projectPath => { - if (!projectPath) { - return; - } - - nextActiveProjectPath = projectPath; - nextActiveRepository = await this.getRepositoryForWorkdirPath(nextActiveProjectPath); - nextActiveResolutionProgress = await this.getResolutionProgressForWorkdirPath(nextActiveProjectPath); - - foundActiveModels = true; - }; - - const activeItem = this.workspace.getActivePaneItem(); + /** + * Derive the git working directory context that should be used for the package's git operations based on the current + * state of the Atom workspace. In priority, this prefers: + * + * - A git working directory that contains the active pane item. + * - A git working directory corresponding to a single Project. + * - When initially activating the package, the working directory that was active when the package was last + * serialized. + * - The current context, unchanged, which may be a `NullWorkdirContext`. + * + * First updates the pool of resident contexts to match all git working directories that correspond to open + * projects and pane items. + */ + async getNextContext(savedState) { + const candidates = new Set(this.project.getDirectories()); + + this.workspace.getPaneItems() + .map(pathForPaneItem) + .filter(itemPath => !this.project.contains(itemPath)) + .forEach(itemPath => candidates.add(itemPath)); + + const workdirs = await Promise.all( + Array.from(candidates, candidate => this.workdirCache.find(candidate)), + ); - if (activeItem) { - if (typeof activeItem.getRepository === 'function') { - // FilePatchController or other GitHub package pane - const repository = activeItem.getRepository(); - if (!repository.isDestroyed()) { - nextActiveRepository = repository; - nextActiveResolutionProgress = this.props.activeResolutionProgress; - nextActiveProjectPath = this.props.activeProjectPath; + this.contextPool.set(workdirs); - foundActiveModels = true; - } - - } else if (typeof activeItem.getPath === 'function') { - // TextEditor-like - const projectPath = this.projectPathForItemPath(activeItem.getPath()); - await setByProjectPath(projectPath); - } + const activeItemPath = pathForPaneItem(this.workspace.getActivePaneItem()); + if (activeItemPath) { + // Prefer an active item if it has a git working directory. + const activeWorkingDir = await this.workdirCache.find(activeItemPath); + if (activeWorkingDir) { return this.contextPool.getContext(activeWorkingDir); } } - if (!foundActiveModels && this.project.getPaths().length === 1) { + if (this.project.getPaths().length === 1) { // Single project - await setByProjectPath(this.project.getPaths()[0]); + const activeWorkingDir = await this.workdirCache.find(this.project.getPaths()[0]); + if (activeWorkingDir) { return this.contextPool.getContext(activeWorkingDir); } } - if (!foundActiveModels && isActivating) { - // Restore models from saved state - if (this.savedState.activeRepositoryPath) { - const projectPath = this.projectPathForItemPath(this.savedState.activeRepositoryPath); - await setByProjectPath(projectPath); - } + // Restore models from saved state. Will return a NullWorkdirContext if this path is not presently + // resident in the pool. + const savedWorkingDir = savedState.activeRepositoryPath; + if (savedWorkingDir) { + return this.contextPool.getContext(savedWorkingDir); } - this.setActiveModels(nextActiveProjectPath, nextActiveRepository, nextActiveResolutionProgress); + return this.activeContext; } - setActiveModels(projectPath, repository, resolutionProgress) { - // Ensure that models are updated again when a non-null repository is destroyed - const isChanging = this.activeProjectPath !== projectPath || - this.activeRepository !== repository || - this.activeResolutionProgress !== resolutionProgress; + setActiveContext(nextActiveContext) { + if (nextActiveContext !== this.activeContext) { + this.contextSubs.dispose(); + this.contextSubs = new CompositeDisposable(); - this.activeProjectPath = projectPath; + this.activeContext = nextActiveContext; - if (this.activeRepository !== repository) { - if (this.changedRepositorySubscription) { this.changedRepositorySubscription.dispose(); } - if (this.destroyedRepositorySubscription) { this.destroyedRepositorySubscription.dispose(); } + this.activeContext.getRepositoryPromise().then(repository => { + if (!repository || repository.isDestroyed()) { + return; + } - if (repository) { - this.changedRepositorySubscription = repository.onDidUpdate(() => { + this.contextSubs.add(repository.onDidUpdate(() => { this.switchboard.didUpdateRepository(); - }); - this.destroyedRepositorySubscription = repository.onDidDestroy(() => { - if (repository === this.activeRepository) { - this.setActiveModels(null, null, null); + })); + this.contextSubs.add(repository.onDidDestroy(() => { + if (repository === this.getActiveRepository()) { + this.setActiveContext(nullWorkdirContext); } this.destroyedRepositorySubscription.dispose(); - }); - } - } + })); + }); - this.activeRepository = repository; - this.activeResolutionProgress = resolutionProgress; + this.activeContext.getChangeObserverPromise().then(observer => { + this.contextSubs.add(observer.onDidChangeWorkdirOrHead(() => { + this.refreshAtomGitRepository(nextActiveContext.getWorkingDirectory()); + })); + }); - if (isChanging) { this.rerender(); } } - refreshAtomGitRepository(repository) { - const repoPath = repository.getWorkingDirectoryPath(); - const atomGitRepo = this.project.getRepositories().find(repo => { - return repo && path.normalize(repo.getWorkingDirectory()) === repoPath; - }); - return atomGitRepo ? atomGitRepo.refreshStatus() : Promise.resolve(); + async updateActiveContext(savedState = {}) { + const nextActiveContext = await this.getNextContext(savedState); + this.setActiveContext(nextActiveContext); } - projectPathForItemPath(filePath) { - if (!filePath) { return null; } - const realFilePath = new File(filePath).getRealPathSync(); - const directory = this.project.getDirectories().find(projectDir => { - const fixedPath = projectDir.getRealPathSync() + path.sep; - return realFilePath.startsWith(fixedPath); + refreshAtomGitRepository(workdir) { + const atomGitRepo = this.project.getRepositories().find(repo => { + return repo && path.normalize(repo.getWorkingDirectory()) === workdir; }); - return directory ? directory.getRealPathSync() : null; - } - - getRepositoryForWorkdirPath(workdirPath) { - const models = this.modelPromisesByProjectPath.get(workdirPath); - return models ? models.repositoryPromise : Promise.resolve(null); + return atomGitRepo ? atomGitRepo.refreshStatus() : Promise.resolve(); } +} - getResolutionProgressForWorkdirPath(workdirPath) { - const models = this.modelPromisesByProjectPath.get(workdirPath); - return models ? models.resolutionProgressPromise : Promise.resolve(null); +function pathForPaneItem(paneItem) { + if (!paneItem) { + return null; } - getChangeObserverForWorkdirPath(workdirPath) { - const models = this.modelPromisesByProjectPath.get(workdirPath); - return models ? models.changeObserverPromise : Promise.resolve(null); + // Likely GitHub package provided pane item + if (typeof paneItem.getWorkingDirectory === 'function') { + return paneItem.getWorkingDirectory(); } - cacheModelsForPaths(addedPaths) { - - return Promise.all(addedPaths.map(async projectPath => { - const repository = await Repository.open(projectPath); - if (!repository) { - return; - } - - this.addRepository(repository); - })); + // TextEditor-like + if (typeof paneItem.getPath === 'function') { + return paneItem.getPath(); } - addRepository(repository) { - const projectPath = repository.getWorkingDirectoryPath(); - repository.setPromptCallback(query => this.controller.promptForCredentials(query)); - - // TODO Since we need to await the repositoryPromise to see if a promise is available or not anyway, other - // calls that are initialized with the promise aren't actually necessary anymore. - const repositoryPromise = Promise.resolve(repository); - - const changeObserver = process.platform === 'linux' - ? new WorkspaceChangeObserver(window, this.workspace, repositoryPromise) - : new FileSystemChangeObserver(repositoryPromise); - this.subscriptions.add( - changeObserver.onDidChange(() => repository.refresh()), - changeObserver.onDidChangeWorkdirOrHead(() => this.refreshAtomGitRepository(repository)), - ); - const changeObserverPromise = changeObserver.start(); - - const savedResolutionProgress = this.savedState.resolutionProgressByPath[projectPath] || {}; - const resolutionProgressPromise = repository.getLastCommit() - .then(commit => new ResolutionProgress(commit.sha, savedResolutionProgress)); - - // Once they resolve, stash the models in modelsByProjectPath for synchronous access when they're needed. - const models = { - repository, - changeObserver: null, - resolutionProgress: null, - }; - this.modelsByProjectPath.set(projectPath, models); - changeObserverPromise.then(() => { - models.changeObserver = changeObserver; - }); - resolutionProgressPromise.then(rp => { - models.resolutionProgress = rp; - }); - - this.modelPromisesByProjectPath.set(projectPath, { - repositoryPromise, - changeObserverPromise, - resolutionProgressPromise, - }); - } - - destroyModelsForPaths(removedPaths) { - return Promise.all( - removedPaths.map(async projectPath => { - const repositoryPromise = this.getRepositoryForWorkdirPath(projectPath); - const changeObserverPromise = this.getChangeObserverForWorkdirPath(projectPath); - this.modelPromisesByProjectPath.delete(projectPath); - - const repository = await repositoryPromise; - const changeObserver = await changeObserverPromise; - // repository and changeObserver may be null if the associated workdir was not a Git repo - repository && repository.destroy(); - changeObserver && await changeObserver.destroy(); - }), - ); - } + // Oh well + return null; } From 49487e92e9a7c11fbff6572993e1cd2ba22c9a4a Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 08:51:49 -0400 Subject: [PATCH 020/161] Handle ENOTDIR when a file path is given --- lib/models/workdir-cache.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/models/workdir-cache.js b/lib/models/workdir-cache.js index 37e924ef67..aab82f4f10 100644 --- a/lib/models/workdir-cache.js +++ b/lib/models/workdir-cache.js @@ -48,12 +48,12 @@ export default class WorkdirCache { const dotGit = path.join(currentDir, '.git'); fs.stat(dotGit, (statError, stat) => { if (statError) { - if (statError.code === 'ENOENT') { + if (statError.code === 'ENOENT' || statError.code === 'ENOTDIR') { // File not found. This is not the directory we're looking for. Continue walking. return walk(); } - reject(statError); + return reject(statError); } if (!stat.isDirectory()) { From e38e7e6f7dc849bfad62aeb15bb72aa4af722b64 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 08:52:13 -0400 Subject: [PATCH 021/161] Resolve other models when repository is null --- lib/models/workdir-context.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index 0e3d28f59f..52f099e923 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -93,6 +93,11 @@ export default class WorkdirContext { repo = this.getRepository(); } + if (!repo) { + this.changeObserverHolder.setValue(null); + this.resolutionProgressHolder.setValue(null); + return; + } if (this.options.promptCallback) { repo.setPromptCallback(this.options.promptCallback); } From 769de8bdc8f7b5d20616e291ef3eee1cf69deaff Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 08:52:42 -0400 Subject: [PATCH 022/161] Forward observer and repository events from WorkdirContext --- lib/models/workdir-context.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index 52f099e923..500563a5de 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -98,14 +98,20 @@ export default class WorkdirContext { this.resolutionProgressHolder.setValue(null); return; } + if (this.options.promptCallback) { repo.setPromptCallback(this.options.promptCallback); } + // Event forwarding from repository + this.subs.add(repo.onDidDestroy(() => this.emitter.emit('did-destroy-repository'))); + this.subs.add(repo.onDidUpdate(() => this.emitter.emit('did-update-repository'))); + const changeObserver = process.platform === 'linux' ? new WorkspaceChangeObserver(this.options.window, this.options.workspace, repo) : new FileSystemChangeObserver(repo); + // Event forwarding from change observer this.subs.add(changeObserver.onDidChange(() => repo.refresh())); this.subs.add(changeObserver.onDidChangeWorkdirOrHead(() => this.emitter.emit('did-change-workdir-or-head'))); @@ -138,6 +144,14 @@ export default class WorkdirContext { return this.emitter.on('did-change-workdir-or-head', callback); } + onDidUpdateRepository(callback) { + return this.emitter.on('did-update-repository', callback); + } + + onDidDestroyRepository(callback) { + return this.emitter.on('did-destroy-repository', callback); + } + getWorkingDirectory() { return this.directory; } From b32a4e2d5296832e43f84adeed12b28277b425f0 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 08:53:24 -0400 Subject: [PATCH 023/161] Forward events from all WorkdirContexts in a pool --- lib/models/workdir-context-pool.js | 38 +++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/models/workdir-context-pool.js b/lib/models/workdir-context-pool.js index 1c1a4517cd..1df2e58d15 100644 --- a/lib/models/workdir-context-pool.js +++ b/lib/models/workdir-context-pool.js @@ -1,5 +1,6 @@ import compareSets from 'compare-sets'; +import {Emitter, CompositeDisposable} from 'event-kit'; import WorkdirContext, {nullWorkdirContext} from './workdir-context'; /** @@ -22,6 +23,7 @@ export default class WorkdirContextPool { this.options = options; this.contexts = new Map(); + this.emitter = new Emitter(); } size() { @@ -45,13 +47,31 @@ export default class WorkdirContextPool { const context = new WorkdirContext(directory, {savedResolutionProgress, ...this.options, ...options}); this.contexts.set(directory, context); + const disposable = new CompositeDisposable(); + const forwardEvent = (subMethod, emitEventName) => { + const emit = () => this.emitter.emit(emitEventName, context); + disposable.add(context[subMethod](emit)); + }; + forwardEvent('onDidChangeWorkdirOrHead', 'did-change-workdir-or-head'); + forwardEvent('onDidUpdateRepository', 'did-update-repository'); + forwardEvent('onDidDestroyRepository', 'did-destroy-repository'); + disposable.add(this.onDidRemoveContext(removed => { + if (removed === context) { + disposable.dispose(); + } + })); + context.create(); } remove(directory) { const existing = this.contexts.get(directory); this.contexts.delete(directory); - existing && existing.destroy(); + + if (existing) { + this.emitter.emit('did-remove-context', existing); + existing.destroy(); + } } set(directories) { @@ -72,6 +92,22 @@ export default class WorkdirContextPool { } } + onDidRemoveContext(callback) { + return this.emitter.on('did-remove-context', callback); + } + + onDidUpdateRepository(callback) { + return this.emitter.on('did-update-repository', callback); + } + + onDidDestroyRepository(callback) { + return this.emitter.on('did-destroy-repository', callback); + } + + onDidChangeWorkdirOrHead(callback) { + return this.emitter.on('did-change-workdir-or-head', callback); + } + clear() { this.withResidentContexts(workdir => this.remove(workdir)); } From 3de282cb0a432932601b103fe7ddbe6fda4e3985 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 08:53:48 -0400 Subject: [PATCH 024/161] Pass options to created WorkdirContexts on set() --- lib/models/workdir-context-pool.js | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/lib/models/workdir-context-pool.js b/lib/models/workdir-context-pool.js index 1df2e58d15..e649a26a64 100644 --- a/lib/models/workdir-context-pool.js +++ b/lib/models/workdir-context-pool.js @@ -9,17 +9,9 @@ import WorkdirContext, {nullWorkdirContext} from './workdir-context'; export default class WorkdirContextPool { /** - * Recognized options: - * - * - `options.resolutionProgressByPath`: saved resolution progress state associated with a previously opened - * working directory. - * - * All other options will be passed to each `WorkdirContext` as it is created. + * Options will be passed to each `WorkdirContext` as it is created. */ constructor(options = {}) { - this.resolutionProgressByPath = options.resolutionProgressByPath || {}; - delete options.resolutionProgressByPath; - this.options = options; this.contexts = new Map(); @@ -42,7 +34,10 @@ export default class WorkdirContextPool { return; } - const savedResolutionProgress = this.resolutionProgressByPath[directory] || {}; + let savedResolutionProgress = {}; + if (options.resolutionProgressByPath) { + savedResolutionProgress = options.resolutionProgressByPath[directory] || {}; + } const context = new WorkdirContext(directory, {savedResolutionProgress, ...this.options, ...options}); this.contexts.set(directory, context); @@ -74,12 +69,12 @@ export default class WorkdirContextPool { } } - set(directories) { + set(directories, options = {}) { const previous = new Set(this.contexts.keys()); const {added, removed} = compareSets(previous, directories); for (const directory of added) { - this.add(directory); + this.add(directory, options); } for (const directory of removed) { this.remove(directory); From 8571ceb1f60e4dbc710408d899d33639b9c97a37 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 08:54:38 -0400 Subject: [PATCH 025/161] It's activeContext, not activeWorkdir --- lib/github-package.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/github-package.js b/lib/github-package.js index 956fdc3892..ae18903be8 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -40,7 +40,7 @@ export default class GithubPackage { this.confirm = confirm; this.activeContextQueue = new AsyncQueue(); - this.activeWorkdir = nullWorkdirContext; + this.activeContext = nullWorkdirContext; this.workdirCache = new WorkdirCache(); this.contextPool = new WorkdirContextPool({ window, From d03159f16dcf4d99e95126020a6afbb201ffa7d7 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 08:56:52 -0400 Subject: [PATCH 026/161] Subscribe to pool events instead of repository and observer events --- lib/github-package.js | 44 ++++++++++++++++--------------------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index ae18903be8..6f9c654be6 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -48,9 +48,23 @@ export default class GithubPackage { promptCallback: query => this.controller.promptForCredentials(query), }); - this.subscriptions = new CompositeDisposable(); - this.switchboard = new Switchboard(); + + // Handle events from all resident contexts. + this.subscriptions = new CompositeDisposable( + this.contextPool.onDidChangeWorkdirOrHead(context => { + this.refreshAtomGitRepository(context.getWorkingDirectory()); + }), + this.contextPool.onDidUpdateRepository(context => { + this.switchboard.didUpdateRepository(context.getRepository()); + }), + this.contextPool.onDidDestroyRepository(context => { + if (context === this.activeContext) { + this.setActiveContext(nullWorkdirContext); + } + }), + ); + this.setupYardstick(); } @@ -277,33 +291,7 @@ export default class GithubPackage { setActiveContext(nextActiveContext) { if (nextActiveContext !== this.activeContext) { - this.contextSubs.dispose(); - this.contextSubs = new CompositeDisposable(); - this.activeContext = nextActiveContext; - - this.activeContext.getRepositoryPromise().then(repository => { - if (!repository || repository.isDestroyed()) { - return; - } - - this.contextSubs.add(repository.onDidUpdate(() => { - this.switchboard.didUpdateRepository(); - })); - this.contextSubs.add(repository.onDidDestroy(() => { - if (repository === this.getActiveRepository()) { - this.setActiveContext(nullWorkdirContext); - } - this.destroyedRepositorySubscription.dispose(); - })); - }); - - this.activeContext.getChangeObserverPromise().then(observer => { - this.contextSubs.add(observer.onDidChangeWorkdirOrHead(() => { - this.refreshAtomGitRepository(nextActiveContext.getWorkingDirectory()); - })); - }); - this.rerender(); } } From 993bae3353dc1f30b2a799d343154d50ab71fc4e Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 08:57:48 -0400 Subject: [PATCH 027/161] Remember and use savedState --- lib/github-package.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 6f9c654be6..0b5ec435ca 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -95,7 +95,7 @@ export default class GithubPackage { } activate(state = {}) { - const savedState = {...defaultState, ...state}; + this.savedState = {...defaultState, ...state}; this.subscriptions.add( this.project.onDidChangePaths(this.scheduleActiveContextUpdate), @@ -125,6 +125,7 @@ export default class GithubPackage { this.workspace.addOpener(IssueishPaneItem.opener), ); + this.scheduleActiveContextUpdate(this.savedState); this.rerender(); } @@ -264,7 +265,7 @@ export default class GithubPackage { Array.from(candidates, candidate => this.workdirCache.find(candidate)), ); - this.contextPool.set(workdirs); + this.contextPool.set(workdirs, savedState); const activeItemPath = pathForPaneItem(this.workspace.getActivePaneItem()); if (activeItemPath) { From 6eba2ac35ce88a8868acc6126d347f8a4c4f6b9f Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 08:58:07 -0400 Subject: [PATCH 028/161] Enumerate contextPool, not just activeContext --- lib/github-package.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/github-package.js b/lib/github-package.js index 0b5ec435ca..58e46661c5 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -134,7 +134,7 @@ export default class GithubPackage { const activeRepositoryPath = activeRepository ? activeRepository.getWorkingDirectoryPath() : null; const resolutionProgressByPath = {}; - this.activeContext.withResidentContexts((workdir, context) => { + this.contextPool.withResidentContexts((workdir, context) => { const resolutionProgress = context.getResolutionProgress(); if (!resolutionProgress || resolutionProgress.isEmpty()) { return; From 34b06ff9b9b2b74016edbd7d16464f13bd7dc146 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 08:58:45 -0400 Subject: [PATCH 029/161] Add newly initialized and clone repositories to the pool --- lib/github-package.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 58e46661c5..b5b1fb9de1 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -211,16 +211,18 @@ export default class GithubPackage { throw new Error('Must specify a project path to create a repository for'); } const repository = await Repository.init(projectPath); - this.addRepository(repository); - await this.scheduleActiveModelUpdate(); + this.contextPool.add(projectPath, {repository}); + + await this.scheduleActiveContextUpdate(); } @autobind async cloneRepositoryForProjectPath(remoteUrl, projectPath) { const repository = await Repository.clone(remoteUrl, projectPath); - this.addRepository(repository); - this.project.addPath(projectPath); - await this.scheduleActiveModelUpdate(); + this.contextPool.add(projectPath, {repository}); + this.projects.addPath(projectPath); + + await this.scheduleActiveContextUpdate(); } getActiveWorkdir() { From 8694514698d169aa811a944c40f6e781cae930b4 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 08:59:00 -0400 Subject: [PATCH 030/161] Right, I renamed that --- lib/github-package.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/github-package.js b/lib/github-package.js index b5b1fb9de1..dad98466bf 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -239,7 +239,7 @@ export default class GithubPackage { @autobind async scheduleActiveContextUpdate(savedState = {}) { - await this.activeContextQueue.push(this.updateActiveModels.bind(this, savedState), {parallel: false}); + await this.activeContextQueue.push(this.updateActiveContext.bind(this, savedState), {parallel: false}); } /** From 6b04880a76c739e06e0ccccf7f94a3383e090821 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 08:59:12 -0400 Subject: [PATCH 031/161] Getter for cleaner testing --- lib/github-package.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/github-package.js b/lib/github-package.js index dad98466bf..ee81445aab 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -237,6 +237,10 @@ export default class GithubPackage { return this.activeContext.getResolutionProgress(); } + getContextPool() { + return this.contextPool; + } + @autobind async scheduleActiveContextUpdate(savedState = {}) { await this.activeContextQueue.push(this.updateActiveContext.bind(this, savedState), {parallel: false}); From e91ce2018801cbd8c4bfb988835bc24f2dd9b459 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 09:00:36 -0400 Subject: [PATCH 032/161] Iron out getNextContext --- lib/github-package.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index ee81445aab..805a7dfe83 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -260,12 +260,12 @@ export default class GithubPackage { * projects and pane items. */ async getNextContext(savedState) { - const candidates = new Set(this.project.getDirectories()); + const candidates = new Set(this.project.getPaths()); - this.workspace.getPaneItems() - .map(pathForPaneItem) - .filter(itemPath => !this.project.contains(itemPath)) - .forEach(itemPath => candidates.add(itemPath)); + const activeItemPath = pathForPaneItem(this.workspace.getActivePaneItem()); + if (activeItemPath) { + candidates.add(activeItemPath); + } const workdirs = await Promise.all( Array.from(candidates, candidate => this.workdirCache.find(candidate)), @@ -273,17 +273,16 @@ export default class GithubPackage { this.contextPool.set(workdirs, savedState); - const activeItemPath = pathForPaneItem(this.workspace.getActivePaneItem()); if (activeItemPath) { - // Prefer an active item if it has a git working directory. + // Prefer an active item const activeWorkingDir = await this.workdirCache.find(activeItemPath); - if (activeWorkingDir) { return this.contextPool.getContext(activeWorkingDir); } + return this.contextPool.getContext(activeWorkingDir); } if (this.project.getPaths().length === 1) { // Single project const activeWorkingDir = await this.workdirCache.find(this.project.getPaths()[0]); - if (activeWorkingDir) { return this.contextPool.getContext(activeWorkingDir); } + return this.contextPool.getContext(activeWorkingDir); } // Restore models from saved state. Will return a NullWorkdirContext if this path is not presently From 6ba316eac17490d3b8ad81c3ec6d56c390f3138b Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 09:00:44 -0400 Subject: [PATCH 033/161] :fire: unused imports --- lib/github-package.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 805a7dfe83..2f44403f68 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -1,19 +1,15 @@ -import {CompositeDisposable, Disposable, File} from 'atom'; +import {CompositeDisposable, Disposable} from 'atom'; import path from 'path'; import React from 'react'; import ReactDom from 'react-dom'; import {autobind} from 'core-decorators'; -import compareSets from 'compare-sets'; import WorkdirCache from './models/workdir-cache'; import {nullWorkdirContext} from './models/workdir-context'; import WorkdirContextPool from './models/workdir-context-pool'; import Repository from './models/repository'; -import FileSystemChangeObserver from './models/file-system-change-observer'; -import WorkspaceChangeObserver from './models/workspace-change-observer'; -import ResolutionProgress from './models/conflicts/resolution-progress'; import StyleCalculator from './models/style-calculator'; import RootController from './controllers/root-controller'; import IssueishPaneItem from './atom-items/issueish-pane-item'; From c318f1970ef8a0e541cef78a958bc3f8bee90516 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 09:00:58 -0400 Subject: [PATCH 034/161] HULK COMMIT GIANT TEST OVERHAUL --- test/github-package.test.js | 499 ++++++++++++++++++------------------ 1 file changed, 254 insertions(+), 245 deletions(-) diff --git a/test/github-package.test.js b/test/github-package.test.js index 0d8db5662d..979aad6655 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -1,16 +1,15 @@ -import {Directory} from 'atom'; - import fs from 'fs'; import path from 'path'; import temp from 'temp'; import until from 'test-until'; import {cloneRepository} from './helpers'; +import {writeFile} from '../lib/helpers'; import GithubPackage from '../lib/github-package'; describe('GithubPackage', function() { let atomEnv, workspace, project, commandRegistry, notificationManager, config, confirm, tooltips, styles; - let githubPackage; + let githubPackage, contextPool; beforeEach(function() { atomEnv = global.buildAtomEnvironment(); @@ -25,6 +24,8 @@ describe('GithubPackage', function() { githubPackage = new GithubPackage( workspace, project, commandRegistry, notificationManager, tooltips, styles, config, confirm, ); + + contextPool = githubPackage.getContextPool(); }); afterEach(async function() { @@ -32,47 +33,29 @@ describe('GithubPackage', function() { atomEnv.destroy(); }); - // Create a Promise that will resolve successfully when all models for all expected working directories have been - // initialized. - function untilModelsReady(workdirs, models = ['respository', 'changeObserver', 'resolutionProgress'], pkg = githubPackage) { - const modelPromiseGetters = models.map(modelName => { - return `get${modelName[0].toUpperCase() + modelName.slice(1)}ForWorkdirPath`; - }); - - const modelReadyForWorkdir = (getter, workdir) => new Promise(resolve => { - const attempt = () => { - pkg[getter](workdir).then(result => { - if (result === null) { - setTimeout(attempt, 100); - } else { - resolve(); - } - }); - }; - attempt(); + function untilWorkdirs(...workdirs) { + return until(`working directories ${workdirs.join(', ')} are loaded`, () => { + return workdirs.every(workdir => contextPool.getContext(workdir).isPresent()); }); + } - const readyPromises = []; - for (let i = 0; i < workdirs.length; i++) { - const workdir = workdirs[i]; - for (let j = 0; j < modelPromiseGetters.length; j++) { - const getter = modelPromiseGetters[j]; - readyPromises.push(modelReadyForWorkdir(getter, workdir)); - } - } - return Promise.all(readyPromises); + function untilRepositories(...workdirs) { + return untilWorkdirs(...workdirs).then(() => Promise.all( + workdirs.map(workdir => contextPool.getContext(workdir).getRepositoryPromise()), + )); } describe('activate()', function() { it('uses models from preexisting projects', async function() { - const workdirPath1 = await cloneRepository('three-files'); - const workdirPath2 = await cloneRepository('three-files'); + const [workdirPath1, workdirPath2] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); project.setPaths([workdirPath1, workdirPath2]); await githubPackage.activate(); - await assert.async.isNotNull(await githubPackage.getRepositoryForWorkdirPath(workdirPath1)); - await assert.async.isNotNull(await githubPackage.getRepositoryForWorkdirPath(workdirPath2)); + await untilRepositories(workdirPath1, workdirPath2); assert.isNull(githubPackage.getActiveRepository()); }); @@ -83,31 +66,29 @@ describe('GithubPackage', function() { await githubPackage.activate(); await assert.async.isNotNull(githubPackage.getActiveRepository()); - assert.equal( - await githubPackage.getActiveRepository().getWorkingDirectoryPath(), - workdirPath, - ); + assert.equal(githubPackage.getActiveWorkdir(), workdirPath); }); it('uses an active model from a preexisting active pane item', async function() { - const workdirPath1 = await cloneRepository('three-files'); - const workdirPath2 = await cloneRepository('three-files'); + const [workdirPath1, workdirPath2] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); project.setPaths([workdirPath1, workdirPath2]); await workspace.open(path.join(workdirPath2, 'a.txt')); await githubPackage.activate(); await assert.async.isNotNull(githubPackage.getActiveRepository()); - assert.equal( - await githubPackage.getActiveRepository().getWorkingDirectoryPath(), - workdirPath2, - ); + assert.equal(githubPackage.getActiveWorkdir(), workdirPath2); }); it('uses an active model from serialized state', async function() { - const workdirPath1 = await cloneRepository('three-files'); - const workdirPath2 = await cloneRepository('three-files'); - const workdirPath3 = await cloneRepository('three-files'); + const [workdirPath1, workdirPath2, workdirPath3] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); project.setPaths([workdirPath1, workdirPath2, workdirPath3]); await githubPackage.activate({ @@ -115,15 +96,14 @@ describe('GithubPackage', function() { }); await assert.async.isNotNull(githubPackage.getActiveRepository()); - assert.equal( - await githubPackage.getActiveRepository().getWorkingDirectoryPath(), - workdirPath2, - ); + assert.equal(githubPackage.getActiveWorkdir(), workdirPath2); }); it('prefers the active model from an active pane item to serialized state', async function() { - const workdirPath1 = await cloneRepository('three-files'); - const workdirPath2 = await cloneRepository('three-files'); + const [workdirPath1, workdirPath2] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); project.setPaths([workdirPath1, workdirPath2]); await workspace.open(path.join(workdirPath2, 'b.txt')); @@ -132,15 +112,14 @@ describe('GithubPackage', function() { }); await assert.async.isNotNull(githubPackage.getActiveRepository()); - assert.equal( - await githubPackage.getActiveRepository().getWorkingDirectoryPath(), - workdirPath2, - ); + assert.equal(githubPackage.getActiveWorkdir(), workdirPath2); }); it('prefers the active model from a single project to serialized state', async function() { - const workdirPath1 = await cloneRepository('three-files'); - const workdirPath2 = await cloneRepository('three-files'); + const [workdirPath1, workdirPath2] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); project.setPaths([workdirPath1]); await githubPackage.activate({ @@ -148,63 +127,84 @@ describe('GithubPackage', function() { }); await assert.async.isNotNull(githubPackage.getActiveRepository()); - assert.equal( - await githubPackage.getActiveRepository().getWorkingDirectoryPath(), - workdirPath1, - ); + assert.equal(githubPackage.getActiveWorkdir(), workdirPath1); }); - }); - describe('changing the project paths', function() { - it('updates the active repository and project path', async function() { - const workdirPath1 = await cloneRepository('three-files'); - const workdirPath2 = await cloneRepository('three-files'); + it('restores the active resolution progress', async function() { + // Repository with a merge conflict, repository without a merge conflict, path without a repository + const workdirMergeConflict = await cloneRepository('merge-conflict'); + const workdirNoConflict = await cloneRepository('three-files'); const nonRepositoryPath = fs.realpathSync(temp.mkdirSync()); fs.writeFileSync(path.join(nonRepositoryPath, 'c.txt')); - project.setPaths([workdirPath1, workdirPath2, nonRepositoryPath]); - fs.writeFileSync(path.join(workdirPath1, 'a.txt'), 'change 1', 'utf8'); + + project.setPaths([workdirMergeConflict, workdirNoConflict, nonRepositoryPath]); await githubPackage.activate(); - sinon.spy(githubPackage, 'rerender'); - await workspace.open(path.join(workdirPath1, 'a.txt')); - const repository1 = await githubPackage.getRepositoryForWorkdirPath(workdirPath1); - await assert.async.strictEqual(githubPackage.getActiveRepository(), repository1); - await assert.async.equal(githubPackage.getActiveProjectPath(), workdirPath1); - await assert.async.equal(githubPackage.rerender.callCount, 1); + // Open a file in the merge conflict repository. + await workspace.open(path.join(workdirMergeConflict, 'modified-on-both-ours.txt')); + await githubPackage.scheduleActiveContextUpdate(); - // Remove repository for open file - project.setPaths([workdirPath2, nonRepositoryPath]); - await assert.async.equal(githubPackage.getActiveProjectPath(), workdirPath1); - await assert.async.isNull(githubPackage.getActiveRepository()); - await assert.async.equal(githubPackage.rerender.callCount, 2); + const resolutionMergeConflict = await contextPool.getContext(workdirMergeConflict).getResolutionProgressPromise(); + await assert.strictEqual(githubPackage.getActiveResolutionProgress(), resolutionMergeConflict); - await workspace.open(path.join(workdirPath2, 'b.txt')); - const repository2 = await githubPackage.getRepositoryForWorkdirPath(workdirPath2); - await assert.async.strictEqual(githubPackage.getActiveRepository(), repository2); - await assert.async.equal(githubPackage.getActiveProjectPath(), workdirPath2); - await assert.async.equal(githubPackage.rerender.callCount, 3); + // Record some resolution progress to recall later + resolutionMergeConflict.reportMarkerCount('modified-on-both-ours.txt', 3); + + // Open a file in the non-merge conflict repository. + await workspace.open(path.join(workdirNoConflict, 'b.txt')); + await githubPackage.scheduleActiveContextUpdate(); + const resolutionNoConflict = await contextPool.getContext(workdirNoConflict).getResolutionProgressPromise(); + assert.strictEqual(githubPackage.getActiveResolutionProgress(), resolutionNoConflict); + assert.isTrue(githubPackage.getActiveResolutionProgress().isEmpty()); + + // Open a file in the workdir with no repository. await workspace.open(path.join(nonRepositoryPath, 'c.txt')); - await assert.async.isNull(githubPackage.getActiveRepository()); - await assert.async.equal(githubPackage.getActiveProjectPath(), nonRepositoryPath); - await assert.async.equal(githubPackage.rerender.callCount, 4); + await githubPackage.scheduleActiveContextUpdate(); + + assert.isNull(githubPackage.getActiveResolutionProgress()); + + // Re-open a file in the merge conflict repository. + await workspace.open(path.join(workdirMergeConflict, 'modified-on-both-theirs.txt')); + await githubPackage.scheduleActiveContextUpdate(); + + assert.strictEqual(githubPackage.getActiveResolutionProgress(), resolutionMergeConflict); + assert.isFalse(githubPackage.getActiveResolutionProgress().isEmpty()); + assert.equal(githubPackage.getActiveResolutionProgress().getRemaining('modified-on-both-ours.txt'), 3); }); + }); - it('destroys all the repositories associated with the removed project folders', async function() { - const workdirPath1 = await cloneRepository('three-files'); - const workdirPath2 = await cloneRepository('three-files'); - const workdirPath3 = await cloneRepository('three-files'); - project.setPaths([workdirPath1, workdirPath2, workdirPath3]); + describe('when the project paths change', function() { + it('adds new workdirs to the pool', async function() { + const [workdirPath1, workdirPath2, workdirPath3] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); + + project.setPaths([workdirPath1, workdirPath2]); await githubPackage.activate(); + await untilWorkdirs(workdirPath1, workdirPath2); + + project.setPaths([workdirPath1, workdirPath2, workdirPath3]); + await untilWorkdirs(workdirPath1, workdirPath2, workdirPath3); + }); - await untilModelsReady([workdirPath1, workdirPath2, workdirPath3], ['repository']); + it('destroys contexts associated with the removed project folders', async function() { + const [workdirPath1, workdirPath2, workdirPath3] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); + project.setPaths([workdirPath1, workdirPath2, workdirPath3]); + await githubPackage.activate(); + await untilRepositories(workdirPath1, workdirPath2, workdirPath3); - const repository1 = await githubPackage.getRepositoryForWorkdirPath(workdirPath1); - const repository2 = await githubPackage.getRepositoryForWorkdirPath(workdirPath2); - const repository3 = await githubPackage.getRepositoryForWorkdirPath(workdirPath3); - assert.isOk(repository1); - assert.isOk(repository2); - assert.isOk(repository3); + const [repository1, repository2, repository3] = await Promise.all( + [workdirPath1, workdirPath2, workdirPath3].map(workdir => { + return contextPool.getContext(workdir).getRepositoryPromise(); + }), + ); sinon.stub(repository1, 'destroy'); sinon.stub(repository2, 'destroy'); @@ -213,139 +213,173 @@ describe('GithubPackage', function() { project.removePath(workdirPath1); project.removePath(workdirPath3); - await until(() => repository1.destroy.callCount === 1, 'repository1 is destroyed'); - await until(() => repository3.destroy.callCount === 1, 'repository3 is destroyed'); - assert.equal(repository2.destroy.callCount, 0); - assert.notEqual(await githubPackage.getRepositoryForWorkdirPath(repository1.getWorkingDirectoryPath()), repository1); - assert.notEqual(await githubPackage.getRepositoryForWorkdirPath(repository3.getWorkingDirectoryPath()), repository3); - assert.equal(await githubPackage.getRepositoryForWorkdirPath(repository2.getWorkingDirectoryPath()), repository2); + await assert.async.equal(repository1.destroy.callCount, 1); + await assert.async.equal(repository3.destroy.callCount, 1); + assert.isFalse(repository2.destroy.called); + + assert.isFalse(contextPool.getContext(workdirPath1).isPresent()); + assert.isFalse(contextPool.getContext(workdirPath3).isPresent()); + assert.isTrue(contextPool.getContext(workdirPath2).isPresent()); }); }); - describe('didChangeActivePaneItem()', function() { - it('updates the active repository', async function() { - const workdirPath1 = await cloneRepository('three-files'); - const workdirPath2 = await cloneRepository('three-files'); + describe('when the active pane item changes', function() { + it('becomes the active context', async function() { + const [workdirPath1, workdirPath2] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); project.setPaths([workdirPath1, workdirPath2]); - fs.writeFileSync(path.join(workdirPath1, 'a.txt'), 'change 1', 'utf8'); - fs.writeFileSync(path.join(workdirPath2, 'b.txt'), 'change 2', 'utf8'); + await githubPackage.activate(); + await untilRepositories(workdirPath1, workdirPath2); + const repository2 = contextPool.getContext(workdirPath2).getRepository(); - await workspace.open(path.join(workdirPath1, 'a.txt')); - githubPackage.didChangeActivePaneItem(); - assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath1)); + assert.isNull(githubPackage.getActiveRepository()); await workspace.open(path.join(workdirPath2, 'b.txt')); - githubPackage.didChangeActivePaneItem(); - assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath2)); + + await assert.async.strictEqual(githubPackage.getActiveRepository(), repository2); }); - }); - describe('updateActiveModels()', function() { - it('updates the active repository based on the active item, setting it to null when the active item is not in a project repository', async function() { - const workdirPath1 = await cloneRepository('three-files'); - const workdirPath2 = await cloneRepository('three-files'); - const nonRepositoryPath = fs.realpathSync(temp.mkdirSync()); - fs.writeFileSync(path.join(nonRepositoryPath, 'c.txt')); - project.setPaths([workdirPath1, workdirPath2, nonRepositoryPath]); + it('adds a new context if not in a project', async function() { + const [workdirPath1, workdirPath2] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); + project.setPaths([workdirPath1]); await githubPackage.activate(); + await untilRepositories(workdirPath1); - await workspace.open(path.join(workdirPath1, 'a.txt')); - await workspace.open(path.join(workdirPath2, 'b.txt')); + await workspace.open(path.join(workdirPath2, 'c.txt')); - await githubPackage.scheduleActiveModelUpdate(); - assert.isNotNull(githubPackage.getActiveRepository()); - assert.equal(githubPackage.getActiveProjectPath(), workdirPath2); - assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath2)); + await untilRepositories(workdirPath1, workdirPath2); + assert.equal(githubPackage.getActiveWorkdir(), workdirPath2); + }); - await workspace.open(path.join(nonRepositoryPath, 'c.txt')); - await githubPackage.scheduleActiveModelUpdate(); - assert.equal(githubPackage.getActiveProjectPath(), nonRepositoryPath); - assert.isNull(githubPackage.getActiveRepository()); + it('removes a context if not in a project', async function() { + const [workdirPath1, workdirPath2] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); + project.setPaths([workdirPath1]); + await githubPackage.activate(); + await workspace.open(path.join(workdirPath2, 'c.txt')); + await untilRepositories(workdirPath1, workdirPath2); - await workspace.open(path.join(workdirPath1, 'a.txt')); - await githubPackage.scheduleActiveModelUpdate(); - assert.equal(githubPackage.getActiveProjectPath(), workdirPath1); - assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath1)); + workspace.getActivePane().destroyActiveItem(); - workspace.getActivePane().activateItem({}); // such as when find & replace results pane is focused - await githubPackage.scheduleActiveModelUpdate(); - assert.equal(githubPackage.getActiveProjectPath(), workdirPath1); - assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath1)); + await assert.async.isFalse(contextPool.getContext(workdirPath2).isPresent()); + }); + }); - await workspace.open(path.join(workdirPath2, 'b.txt')); - await githubPackage.scheduleActiveModelUpdate(); - assert.equal(githubPackage.getActiveProjectPath(), workdirPath2); - assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(workdirPath2)); + describe('scheduleActiveContextUpdate()', function() { + beforeEach(function() { + // Necessary since we skip activate() + githubPackage.savedState = {}; + }); - project.removePath(workdirPath2); - await githubPackage.scheduleActiveModelUpdate(); - assert.isNull(githubPackage.getActiveProjectPath()); - assert.isNull(githubPackage.getActiveRepository()); + it('prefers the context of the active pane item', async function() { + const [workdirPath1, workdirPath2, workdirPath3] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); + project.setPaths([workdirPath1]); + await workspace.open(path.join(workdirPath2, 'a.txt')); - project.removePath(workdirPath1); - await githubPackage.scheduleActiveModelUpdate(); - assert.isNull(githubPackage.getActiveProjectPath()); - assert.isNull(githubPackage.getActiveRepository()); + await githubPackage.scheduleActiveContextUpdate({ + activeRepositoryPath: workdirPath3, + }); + + assert.equal(githubPackage.getActiveWorkdir(), workdirPath2); + }); + + it('uses an empty context when the active item is not in a git repository', async function() { + const nonRepositoryPath = fs.realpathSync(temp.mkdirSync()); + const workdir = await cloneRepository('three-files'); + project.setPaths([nonRepositoryPath, workdir]); + await writeFile(path.join(nonRepositoryPath, 'a.txt'), 'stuff'); + await workspace.open(path.join(nonRepositoryPath, 'a.txt')); + + await githubPackage.scheduleActiveContextUpdate(); - await workspace.open(path.join(workdirPath1, 'a.txt')); - await githubPackage.scheduleActiveModelUpdate(); - assert.isNull(githubPackage.getActiveProjectPath()); assert.isNull(githubPackage.getActiveRepository()); }); - it('defaults to a single repository even without an active pane item', async function() { - const workdirPath = await cloneRepository('three-files'); - project.setPaths([workdirPath]); - await githubPackage.activate(); + it('uses the context of a single open project', async function() { + const [workdirPath1, workdirPath2] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); + project.setPaths([workdirPath1]); - await githubPackage.scheduleActiveModelUpdate(); + await githubPackage.scheduleActiveContextUpdate({ + activeRepositoryPath: workdirPath2, + }); - const repository = await githubPackage.getRepositoryForWorkdirPath(workdirPath); - await assert.async.strictEqual(githubPackage.getActiveRepository(), repository); + assert.equal(githubPackage.getActiveWorkdir(), workdirPath1); }); - it('updates the active resolution progress', async function() { - // Repository with a merge conflict, repository without a merge conflict, path without a repository - const workdirMergeConflict = await cloneRepository('merge-conflict'); - const workdirNoConflict = await cloneRepository('three-files'); + it('uses a null context with a single open project without a git workdir', async function() { const nonRepositoryPath = fs.realpathSync(temp.mkdirSync()); - fs.writeFileSync(path.join(nonRepositoryPath, 'c.txt')); + project.setPaths([nonRepositoryPath]); - project.setPaths([workdirMergeConflict, workdirNoConflict, nonRepositoryPath]); - await githubPackage.activate(); - await untilModelsReady([workdirMergeConflict, workdirNoConflict], ['resolutionProgress']); + await githubPackage.scheduleActiveContextUpdate(); - // Open a file in the merge conflict repository. - await workspace.open(path.join(workdirMergeConflict, 'modified-on-both-ours.txt')); - await githubPackage.scheduleActiveModelUpdate(); + assert.isNull(githubPackage.getActiveRepository()); + }); - const resolutionMergeConflict = await githubPackage.getResolutionProgressForWorkdirPath(workdirMergeConflict); - await assert.strictEqual(githubPackage.getActiveResolutionProgress(), resolutionMergeConflict); + it('activates a saved context state', async function() { + const [workdirPath1, workdirPath2] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); + project.setPaths([workdirPath1, workdirPath2]); - // Record some resolution progress to recall later - resolutionMergeConflict.reportMarkerCount('modified-on-both-ours.txt', 3); + await githubPackage.scheduleActiveContextUpdate({ + activeRepositoryPath: workdirPath2, + }); - // Open a file in the non-merge conflict repository. - await workspace.open(path.join(workdirNoConflict, 'b.txt')); - await githubPackage.scheduleActiveModelUpdate(); + assert.equal(githubPackage.getActiveWorkdir(), workdirPath2); + }); - const resolutionNoConflict = await githubPackage.getResolutionProgressForWorkdirPath(workdirNoConflict); - await assert.strictEqual(githubPackage.getActiveResolutionProgress(), resolutionNoConflict); - assert.isTrue(githubPackage.getActiveResolutionProgress().isEmpty()); + it('falls back to keeping the context the same', async function() { + const [workdirPath1, workdirPath2] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); + project.setPaths([workdirPath1, workdirPath2]); - // Open a file in the workdir with no repository. - await workspace.open(path.join(nonRepositoryPath, 'c.txt')); - await githubPackage.scheduleActiveModelUpdate(); + contextPool.set([workdirPath1, workdirPath2]); + githubPackage.setActiveContext(contextPool.getContext(workdirPath1)); - await assert.isNull(githubPackage.getActiveResolutionProgress()); + await githubPackage.scheduleActiveContextUpdate(); - // Re-open a file in the merge conflict repository. - await workspace.open(path.join(workdirMergeConflict, 'modified-on-both-theirs.txt')); - await githubPackage.scheduleActiveModelUpdate(); + assert.equal(githubPackage.getActiveWorkdir(), workdirPath1); + }); - await assert.strictEqual(githubPackage.getActiveResolutionProgress(), resolutionMergeConflict); - assert.isFalse(githubPackage.getActiveResolutionProgress().isEmpty()); - assert.equal(githubPackage.getActiveResolutionProgress().getRemaining('modified-on-both-ours.txt'), 3); + it('discovers a context from an open subdirectory', async function() { + const workdirPath = await cloneRepository('three-files'); + const projectPath = path.join(workdirPath, 'subdir-1'); + project.setPaths([projectPath]); + + await githubPackage.scheduleActiveContextUpdate(); + + assert.equal(githubPackage.getActiveWorkdir(), workdirPath); + }); + + it('reverts to an empty context if the active repository is destroyed', async function() { + const workdirPath = await cloneRepository('three-files'); + project.setPaths([workdirPath]); + + await githubPackage.scheduleActiveContextUpdate(); + + const repository = await contextPool.getContext(workdirPath).getRepositoryPromise(); + assert.isNotNull(repository); + + repository.destroy(); + + assert.isNull(githubPackage.getActiveRepository()); }); // Don't worry about this on Windows as it's not a common op @@ -355,13 +389,10 @@ describe('GithubPackage', function() { const symlinkPath = fs.realpathSync(temp.mkdirSync()) + '-symlink'; fs.symlinkSync(workdirPath, symlinkPath); project.setPaths([symlinkPath]); - await githubPackage.activate(); - await untilModelsReady([workdirPath], ['repository']); - await workspace.open(path.join(symlinkPath, 'a.txt')); - await githubPackage.scheduleActiveModelUpdate(); - assert.isOk(githubPackage.getActiveRepository()); + await githubPackage.scheduleActiveContextUpdate(); + await assert.async.isOk(githubPackage.getActiveRepository()); }); } }); @@ -371,38 +402,24 @@ describe('GithubPackage', function() { let workdirPath2, atomGitRepository2, repository2; beforeEach(async function() { - workdirPath1 = await cloneRepository('three-files'); - workdirPath2 = await cloneRepository('three-files'); + [workdirPath1, workdirPath2] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); fs.writeFileSync(path.join(workdirPath1, 'c.txt'), 'ch-ch-ch-changes', 'utf8'); fs.writeFileSync(path.join(workdirPath2, 'c.txt'), 'ch-ch-ch-changes', 'utf8'); project.setPaths([workdirPath1, workdirPath2]); await githubPackage.activate(); - - // Wait for all change observers to be present. - const changeObserverForWorkdir = workdirPath => new Promise(resolve => { - const attempt = () => { - githubPackage.getChangeObserverForWorkdirPath(workdirPath).then(result => { - if (result === null) { - setTimeout(attempt, 100); - } else { - resolve(); - } - }); - }; - attempt(); - }); - await Promise.all( - [workdirPath1, workdirPath2].map(workdirPath => changeObserverForWorkdir(workdirPath)), - ); + await untilRepositories(workdirPath1, workdirPath2); [atomGitRepository1, atomGitRepository2] = githubPackage.project.getRepositories(); sinon.stub(atomGitRepository1, 'refreshStatus'); sinon.stub(atomGitRepository2, 'refreshStatus'); - repository1 = await githubPackage.getRepositoryForWorkdirPath(workdirPath1); - repository2 = await githubPackage.getRepositoryForWorkdirPath(workdirPath2); + repository1 = contextPool.getContext(workdirPath1).getRepository(); + repository2 = contextPool.getContext(workdirPath2).getRepository(); sinon.stub(repository1, 'refresh'); sinon.stub(repository2, 'refresh'); }); @@ -444,7 +461,7 @@ describe('GithubPackage', function() { }); }); - describe('#createRepositoryForProjectPath', function() { + describe('createRepositoryForProjectPath()', function() { it('creates and sets a repository for the given project path', async function() { const workdirPath1 = await cloneRepository('three-files'); const workdirPath2 = await cloneRepository('three-files'); @@ -457,36 +474,28 @@ describe('GithubPackage', function() { await assert.async.isNull(githubPackage.getActiveRepository()); await githubPackage.createRepositoryForProjectPath(nonRepositoryPath); assert.isOk(githubPackage.getActiveRepository()); - assert.equal(githubPackage.getActiveRepository(), await githubPackage.getRepositoryForWorkdirPath(nonRepositoryPath)); - }); - }); - - describe('#projectPathForItemPath', function() { - it('does not error when the path is falsy (e.g. new unsaved file)', function() { - sinon.stub(project, 'getDirectories').returns([new Directory('path')]); - assert.doesNotThrow(() => { - githubPackage.projectPathForItemPath(null); - }); - }); - - it('returns the correct path when the item path starts with the project path but the item path is not in the project', function() { - const dirs = [path.join('path', 'to', 'my'), path.join('path', 'to', 'my-project')].map(p => new Directory(p)); - sinon.stub(project, 'getDirectories').returns(dirs); - assert.equal(githubPackage.projectPathForItemPath(path.join('path', 'to', 'my-project', 'file.txt')), path.join('path', 'to', 'my-project')); + assert.strictEqual( + githubPackage.getActiveRepository(), + await contextPool.getContext(nonRepositoryPath).getRepository(), + ); }); }); describe('serialized state', function() { + function resolutionProgressFrom(pkg, workdir) { + return pkg.getContextPool().getContext(workdir).getResolutionProgressPromise(); + } + it('restores nonempty resolution progress', async function() { const workdirMergeConflict = await cloneRepository('merge-conflict'); const workdirNoConflict = await cloneRepository('three-files'); project.setPaths([workdirMergeConflict, workdirNoConflict]); await githubPackage.activate(); - await untilModelsReady([workdirMergeConflict, workdirNoConflict], ['resolutionProgress']); + await githubPackage.scheduleActiveContextUpdate(); // Record some state to recover later. - const resolutionMergeConflict0 = await githubPackage.getResolutionProgressForWorkdirPath(workdirMergeConflict); + const resolutionMergeConflict0 = await resolutionProgressFrom(githubPackage, workdirMergeConflict); resolutionMergeConflict0.reportMarkerCount('modified-on-both-ours.txt', 3); const payload = githubPackage.serialize(); @@ -500,10 +509,10 @@ describe('GithubPackage', function() { workspace, project, commandRegistry, notificationManager, tooltips, styles, config, confirm, ); await githubPackage1.activate(payload); - await untilModelsReady([workdirMergeConflict, workdirNoConflict], ['resolutionProgress'], githubPackage1); + await githubPackage1.scheduleActiveContextUpdate(); - const resolutionMergeConflict1 = await githubPackage1.getResolutionProgressForWorkdirPath(workdirMergeConflict); - const resolutionNoConflict1 = await githubPackage1.getResolutionProgressForWorkdirPath(workdirNoConflict); + const resolutionMergeConflict1 = await resolutionProgressFrom(githubPackage1, workdirMergeConflict); + const resolutionNoConflict1 = await resolutionProgressFrom(githubPackage1, workdirNoConflict); assert.isFalse(resolutionMergeConflict1.isEmpty()); assert.equal(resolutionMergeConflict1.getRemaining('modified-on-both-ours.txt'), 3); From bca60166054c1c409b61b01c138c9dc26bd6c4fa Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 09:04:39 -0400 Subject: [PATCH 035/161] Import Disposables from event-kit --- lib/models/workdir-context.js | 2 +- lib/switchboard.js | 2 +- test/models/workdir-context.test.js | 2 +- test/switchboard.test.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index 500563a5de..a037abbc73 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -1,4 +1,4 @@ -import {Emitter, CompositeDisposable} from 'atom'; // FIXME import from event-kit +import {Emitter, CompositeDisposable} from 'event-kit'; import Repository from './repository'; import ResolutionProgress from './conflicts/resolution-progress'; diff --git a/lib/switchboard.js b/lib/switchboard.js index 50c4e2d27d..a00cb14c6c 100644 --- a/lib/switchboard.js +++ b/lib/switchboard.js @@ -1,4 +1,4 @@ -import {Emitter} from 'atom'; // FIXME import from event-kit instead +import {Emitter} from 'event-kit'; /* * Register callbacks and construct Promises to wait for the next occurrence of specific events that occur throughout diff --git a/test/models/workdir-context.test.js b/test/models/workdir-context.test.js index f51d34e8a8..3ae8c42223 100644 --- a/test/models/workdir-context.test.js +++ b/test/models/workdir-context.test.js @@ -1,4 +1,4 @@ -import {CompositeDisposable} from 'atom'; +import {CompositeDisposable} from 'event-kit'; import {cloneRepository} from '../helpers'; diff --git a/test/switchboard.test.js b/test/switchboard.test.js index 1403a6634e..41e624fd6d 100644 --- a/test/switchboard.test.js +++ b/test/switchboard.test.js @@ -1,4 +1,4 @@ -import {CompositeDisposable} from 'atom'; // FIXME import from event-kit +import {CompositeDisposable} from 'event-kit'; import Switchboard from '../lib/switchboard'; describe('Switchboard', function() { From a740ed4a8a1a04250f4747f53ffbd181f4837cd7 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 09:14:19 -0400 Subject: [PATCH 036/161] Create NullWorkdirContexts that track working directory --- lib/github-package.js | 6 +++--- lib/models/workdir-context-pool.js | 4 ++-- lib/models/workdir-context.js | 8 +++----- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 2b36bb234c..55cec9fd0e 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -7,7 +7,7 @@ import ReactDom from 'react-dom'; import {autobind} from 'core-decorators'; import WorkdirCache from './models/workdir-cache'; -import {nullWorkdirContext} from './models/workdir-context'; +import {NullWorkdirContext} from './models/workdir-context'; import WorkdirContextPool from './models/workdir-context-pool'; import Repository from './models/repository'; import StyleCalculator from './models/style-calculator'; @@ -36,7 +36,7 @@ export default class GithubPackage { this.confirm = confirm; this.activeContextQueue = new AsyncQueue(); - this.activeContext = nullWorkdirContext; + this.activeContext = new NullWorkdirContext(null); this.workdirCache = new WorkdirCache(); this.contextPool = new WorkdirContextPool({ window, @@ -56,7 +56,7 @@ export default class GithubPackage { }), this.contextPool.onDidDestroyRepository(context => { if (context === this.activeContext) { - this.setActiveContext(nullWorkdirContext); + this.setActiveContext(new NullWorkdirContext(context.getWorkingDirectory())); } }), ); diff --git a/lib/models/workdir-context-pool.js b/lib/models/workdir-context-pool.js index e649a26a64..647537c263 100644 --- a/lib/models/workdir-context-pool.js +++ b/lib/models/workdir-context-pool.js @@ -1,7 +1,7 @@ import compareSets from 'compare-sets'; import {Emitter, CompositeDisposable} from 'event-kit'; -import WorkdirContext, {nullWorkdirContext} from './workdir-context'; +import WorkdirContext, {NullWorkdirContext} from './workdir-context'; /** * Manage a WorkdirContext for each open directory. @@ -26,7 +26,7 @@ export default class WorkdirContextPool { * Access the context mapped to a known directory. */ getContext(directory) { - return this.contexts.get(directory) || nullWorkdirContext; + return this.contexts.get(directory) || new NullWorkdirContext(directory); } add(directory, options = {}) { diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index a037abbc73..408a24232a 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -203,9 +203,9 @@ export default class WorkdirContext { } } -class NullWorkdirContext extends WorkdirContext { - constructor() { - super(null, {}); +export class NullWorkdirContext extends WorkdirContext { + constructor(directory) { + super(directory, {}); this.repositoryHolder.setValue(null); this.changeObserverHolder.setValue(null); @@ -224,5 +224,3 @@ class NullWorkdirContext extends WorkdirContext { return Promise.resolve(); } } - -export const nullWorkdirContext = new NullWorkdirContext(); From 71dfb8b8eb46aa1f3963c2541b6b9cfe289bf3ff Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 09:16:26 -0400 Subject: [PATCH 037/161] Right, I changed the way resolution progress state is passed --- test/models/workdir-context-pool.test.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/test/models/workdir-context-pool.test.js b/test/models/workdir-context-pool.test.js index a447015c1f..b4a10a6e86 100644 --- a/test/models/workdir-context-pool.test.js +++ b/test/models/workdir-context-pool.test.js @@ -5,7 +5,7 @@ import WorkdirContextPool from '../../lib/models/workdir-context-pool'; describe('WorkdirContextPool', function() { let pool; - let mockWindow, mockWorkspace, mockResolutionProgressState; + let mockWindow, mockWorkspace; beforeEach(function() { mockWindow = { @@ -17,12 +17,9 @@ describe('WorkdirContextPool', function() { observeTextEditors: sinon.spy(), }; - mockResolutionProgressState = {}; - pool = new WorkdirContextPool({ window: mockWindow, workspace: mockWorkspace, - resolutionProgressByPath: mockResolutionProgressState, }); }); @@ -73,12 +70,14 @@ describe('WorkdirContextPool', function() { }); it('passes appropriate serialized state to the resolution progress', async function() { - mockResolutionProgressState[workingDirectory] = { - revision: '66d11860af6d28eb38349ef83de475597cb0e8b4', - paths: {'a.txt': 12}, + const resolutionProgressByPath = { + [workingDirectory]: { + revision: '66d11860af6d28eb38349ef83de475597cb0e8b4', + paths: {'a.txt': 12}, + }, }; - pool.add(workingDirectory); + pool.add(workingDirectory, {resolutionProgressByPath}); const resolutionProgress = await pool.getContext(workingDirectory).getResolutionProgressPromise(); assert.isNotNull(resolutionProgress); From dd38b9fe72ab9487beefffb98a8e5bf9219c9434 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 09:29:06 -0400 Subject: [PATCH 038/161] Implement getWorkingDirectory() in FilePatchController --- lib/controllers/file-patch-controller.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/controllers/file-patch-controller.js b/lib/controllers/file-patch-controller.js index 53089207b2..ec43c867ed 100644 --- a/lib/controllers/file-patch-controller.js +++ b/lib/controllers/file-patch-controller.js @@ -9,6 +9,7 @@ import FilePatchView from '../views/file-patch-view'; export default class FilePatchController extends React.Component { static propTypes = { + activeWorkingDirectory: React.PropTypes.string, repository: React.PropTypes.object, commandRegistry: React.PropTypes.object.isRequired, filePatch: React.PropTypes.object.isRequired, @@ -222,6 +223,13 @@ export default class FilePatchController extends React.Component { return this.props.repository.hasDiscardHistory(this.props.filePatch.getPath()); } + /** + * Used to detect the context when this PaneItem is active + */ + getWorkingDirectory() { + return this.props.activeWorkingDirectory; + } + destroy() { this.emitter.emit('did-destroy'); } From 4a7ccf014b7629e5820fd767232eec10012ade23 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 09:29:34 -0400 Subject: [PATCH 039/161] Rename activeProjectPath for accuracy --- lib/controllers/root-controller.js | 5 +++-- lib/github-package.js | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index b119ad6332..33858b34a1 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -43,7 +43,7 @@ export default class RootController extends React.Component { tooltips: React.PropTypes.object.isRequired, config: React.PropTypes.object.isRequired, confirm: React.PropTypes.func.isRequired, - activeProjectPath: React.PropTypes.string, + activeWorkingDirectory: React.PropTypes.string, createRepositoryForProjectPath: React.PropTypes.func, cloneRepositoryForProjectPath: React.PropTypes.func, repository: React.PropTypes.object, @@ -202,6 +202,7 @@ export default class RootController extends React.Component { ref={c => { this.filePatchControllerPane = c; }} onDidCloseItem={() => { this.setState({...nullFilePatchState}); }}> Date: Tue, 11 Apr 2017 09:36:25 -0400 Subject: [PATCH 040/161] untilRepositories() won't wait until the active context is set --- test/github-package.test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/github-package.test.js b/test/github-package.test.js index 979aad6655..cad42c4659 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -252,8 +252,7 @@ describe('GithubPackage', function() { await workspace.open(path.join(workdirPath2, 'c.txt')); - await untilRepositories(workdirPath1, workdirPath2); - assert.equal(githubPackage.getActiveWorkdir(), workdirPath2); + await assert.async.equal(githubPackage.getActiveWorkdir(), workdirPath2); }); it('removes a context if not in a project', async function() { From bbb6fdcb5cfd16c1e4347e8bc4b1dcf531b5a705 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 09:41:07 -0400 Subject: [PATCH 041/161] Wait for Repository creation --- test/github-package.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/github-package.test.js b/test/github-package.test.js index cad42c4659..de8f34174f 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -472,6 +472,8 @@ describe('GithubPackage', function() { await workspace.open(path.join(nonRepositoryPath, 'c.txt')); await assert.async.isNull(githubPackage.getActiveRepository()); await githubPackage.createRepositoryForProjectPath(nonRepositoryPath); + await contextPool.getContext(nonRepositoryPath).getRepositoryPromise(); + assert.isOk(githubPackage.getActiveRepository()); assert.strictEqual( githubPackage.getActiveRepository(), From 03d7471efb43cecbc7da816a18fcbda2aebe3ff1 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 10:23:09 -0400 Subject: [PATCH 042/161] Replace existing contexts within a pool --- lib/models/workdir-context-pool.js | 5 +++ test/models/workdir-context-pool.test.js | 53 +++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/lib/models/workdir-context-pool.js b/lib/models/workdir-context-pool.js index 647537c263..3e8507a5bd 100644 --- a/lib/models/workdir-context-pool.js +++ b/lib/models/workdir-context-pool.js @@ -59,6 +59,11 @@ export default class WorkdirContextPool { context.create(); } + replace(directory, options = {}) { + this.remove(directory); + this.add(directory, options); + } + remove(directory) { const existing = this.contexts.get(directory); this.contexts.delete(directory); diff --git a/test/models/workdir-context-pool.test.js b/test/models/workdir-context-pool.test.js index b4a10a6e86..38d926127e 100644 --- a/test/models/workdir-context-pool.test.js +++ b/test/models/workdir-context-pool.test.js @@ -1,4 +1,6 @@ -import {cloneRepository} from '../helpers'; +import temp from 'temp'; + +import {cloneRepository, buildRepository} from '../helpers'; import Repository from '../../lib/models/repository'; import WorkdirContextPool from '../../lib/models/workdir-context-pool'; @@ -85,6 +87,55 @@ describe('WorkdirContextPool', function() { }); }); + describe('replace', function() { + it('adds a WorkdirContext if one is absent', function() { + const directory = temp.mkdirSync(); + + assert.equal(pool.size(), 0); + assert.isFalse(pool.getContext(directory).isPresent()); + + pool.replace(directory); + + assert.equal(pool.size(), 1); + assert.isTrue(pool.getContext(directory).isPresent()); + }); + + it('removes an existing WorkdirContext if one is present', async function() { + const directory = await cloneRepository('three-files'); + + pool.add(directory); + assert.equal(pool.size(), 1); + assert.isTrue(pool.getContext(directory).isPresent()); + const original = await pool.getContext(directory).getRepositoryPromise(); + + pool.replace(directory); + assert.equal(pool.size(), 1); + assert.isTrue(pool.getContext(directory).isPresent()); + const replaced = await pool.getContext(directory).getRepositoryPromise(); + + assert.notStrictEqual(original, replaced); + }); + + it('passes a Repository to the created WorkdirContext', async function() { + const directory = await cloneRepository('three-files'); + + pool.add(directory); + assert.equal(pool.size(), 1); + assert.isTrue(pool.getContext(directory).isPresent()); + const original = await pool.getContext(directory).getRepositoryPromise(); + + const replacement = await buildRepository(directory); + + pool.replace(directory, {repository: replacement}); + assert.equal(pool.size(), 1); + assert.isTrue(pool.getContext(directory).isPresent()); + const replaced = await pool.getContext(directory).getRepositoryPromise(); + + assert.notStrictEqual(original, replaced); + assert.strictEqual(replacement, replaced); + }); + }); + describe('remove', function() { let existingDirectory, existingContext; From 5387fd04651fc9e5ce291d54bf7493eaa3695b1a Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 10:58:09 -0400 Subject: [PATCH 043/161] Invalidate WorkdirCache entries by subdirectory --- lib/models/workdir-cache.js | 9 ++++++ test/models/workdir-cache.test.js | 46 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/lib/models/workdir-cache.js b/lib/models/workdir-cache.js index aab82f4f10..3c0c7b1c77 100644 --- a/lib/models/workdir-cache.js +++ b/lib/models/workdir-cache.js @@ -34,6 +34,15 @@ export default class WorkdirCache { } } + async invalidate(baseDir) { + const resolvedBase = await this.resolvePath(baseDir); + for (const cachedPath of this.known.keys()) { + if (cachedPath.startsWith(resolvedBase)) { + this.known.delete(cachedPath); + } + } + } + resolvePath(unresolvedPath) { return new Promise((resolve, reject) => { fs.realpath(unresolvedPath, (err, resolved) => (err ? reject(err) : resolve(resolved))); diff --git a/test/models/workdir-cache.test.js b/test/models/workdir-cache.test.js index 8315f3fcaf..31963dcec0 100644 --- a/test/models/workdir-cache.test.js +++ b/test/models/workdir-cache.test.js @@ -60,6 +60,52 @@ describe('WorkdirCache', function() { assert.isFalse(cache.walkToRoot.called); }); + it('removes cached entries for all subdirectories of an entry', async function() { + const [dir0, dir1] = await Promise.all([ + cloneRepository('three-files'), + cloneRepository('three-files'), + ]); + + const pathsToCheck = [ + dir0, + path.join(dir0, 'a.txt'), + path.join(dir0, 'subdir-1'), + path.join(dir0, 'subdir-1', 'b.txt'), + dir1, + ]; + const expectedWorkdirs = [ + dir0, dir0, dir0, dir0, dir1, + ]; + + // Prime the cache + const initial = await Promise.all( + pathsToCheck.map(input => cache.find(input)), + ); + assert.deepEqual(initial, expectedWorkdirs); + + // Re-lookup and hit the cache + sinon.spy(cache, 'walkToRoot'); + const relookup = await Promise.all( + pathsToCheck.map(input => cache.find(input)), + ); + assert.deepEqual(relookup, expectedWorkdirs); + assert.equal(cache.walkToRoot.callCount, 0); + + // Clear dir0 + await cache.invalidate(dir0); + + // Re-lookup and hit the cache once + const after = await Promise.all( + pathsToCheck.map(input => cache.find(input)), + ); + assert.deepEqual(after, expectedWorkdirs); + assert.isTrue(cache.walkToRoot.calledWith(dir0)); + assert.isTrue(cache.walkToRoot.calledWith(path.join(dir0, 'a.txt'))); + assert.isTrue(cache.walkToRoot.calledWith(path.join(dir0, 'subdir-1'))); + assert.isTrue(cache.walkToRoot.calledWith(path.join(dir0, 'subdir-1', 'b.txt'))); + assert.isFalse(cache.walkToRoot.calledWith(dir1)); + }); + it('clears the cache when the maximum size is exceeded', async function() { const dirs = await Promise.all( Array(6).fill(null, 0, 6).map(() => cloneRepository('three-files')), From 6648aa1f0f511a93854803108ce48073030ba293 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 11:00:46 -0400 Subject: [PATCH 044/161] Invalidate the workdir cache when an init has been performed --- lib/github-package.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 8002be3c3e..5428c0019e 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -207,7 +207,8 @@ export default class GithubPackage { throw new Error('Must specify a project path to create a repository for'); } const repository = await Repository.init(projectPath); - this.contextPool.add(projectPath, {repository}); + this.contextPool.replace(projectPath, {repository}); + this.workdirCache.invalidate(projectPath); await this.scheduleActiveContextUpdate(); } @@ -215,7 +216,8 @@ export default class GithubPackage { @autobind async cloneRepositoryForProjectPath(remoteUrl, projectPath) { const repository = await Repository.clone(remoteUrl, projectPath); - this.contextPool.add(projectPath, {repository}); + this.contextPool.replace(projectPath, {repository}); + this.workdirCache.invalidate(projectPath); this.projects.addPath(projectPath); await this.scheduleActiveContextUpdate(); From e6db0527923ba8dc58d586842f175f21a1a3c99b Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 11:38:08 -0400 Subject: [PATCH 045/161] Rerender when the Repository loads --- lib/github-package.js | 1 + lib/models/workdir-context-pool.js | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/github-package.js b/lib/github-package.js index 5428c0019e..8e5f18785e 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -51,6 +51,7 @@ export default class GithubPackage { this.contextPool.onDidChangeWorkdirOrHead(context => { this.refreshAtomGitRepository(context.getWorkingDirectory()); }), + this.contextPool.onDidLoadRepository(context => context === this.activeContext && this.rerender()), this.contextPool.onDidUpdateRepository(context => { this.switchboard.didUpdateRepository(context.getRepository()); }), diff --git a/lib/models/workdir-context-pool.js b/lib/models/workdir-context-pool.js index 3e8507a5bd..23e1cf40b2 100644 --- a/lib/models/workdir-context-pool.js +++ b/lib/models/workdir-context-pool.js @@ -43,6 +43,7 @@ export default class WorkdirContextPool { this.contexts.set(directory, context); const disposable = new CompositeDisposable(); + const forwardEvent = (subMethod, emitEventName) => { const emit = () => this.emitter.emit(emitEventName, context); disposable.add(context[subMethod](emit)); @@ -50,6 +51,14 @@ export default class WorkdirContextPool { forwardEvent('onDidChangeWorkdirOrHead', 'did-change-workdir-or-head'); forwardEvent('onDidUpdateRepository', 'did-update-repository'); forwardEvent('onDidDestroyRepository', 'did-destroy-repository'); + + const forwardPromise = (subMethod, emitEventName) => { + context[subMethod]().then(() => this.emitter.emit(emitEventName, context)); + }; + forwardPromise('getRepositoryPromise', 'did-load-repository'); + forwardPromise('getResolutionProgressPromise', 'did-load-resolution-progress'); + forwardPromise('getChangeObserverPromise', 'did-load-change-observer'); + disposable.add(this.onDidRemoveContext(removed => { if (removed === context) { disposable.dispose(); @@ -96,6 +105,18 @@ export default class WorkdirContextPool { return this.emitter.on('did-remove-context', callback); } + onDidLoadRepository(callback) { + return this.emitter.on('did-load-repository', callback); + } + + onDidLoadResolutionProgress(callback) { + return this.emitter.on('did-load-resolution-progress', callback); + } + + onDidLoadChangeObserver(callback) { + return this.emitter.on('did-load-change-observer', callback); + } + onDidUpdateRepository(callback) { return this.emitter.on('did-update-repository', callback); } From b3a324fa7d6d82dd19ff3f59b7d116b6d5bcc2c8 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 12:07:16 -0400 Subject: [PATCH 046/161] Typo fix --- lib/github-package.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/github-package.js b/lib/github-package.js index 8e5f18785e..51e9275009 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -219,7 +219,7 @@ export default class GithubPackage { const repository = await Repository.clone(remoteUrl, projectPath); this.contextPool.replace(projectPath, {repository}); this.workdirCache.invalidate(projectPath); - this.projects.addPath(projectPath); + this.project.addPath(projectPath); await this.scheduleActiveContextUpdate(); } From 9aab04c693b73f05da09665c80d2fb4249443d6f Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 12:07:44 -0400 Subject: [PATCH 047/161] Wait until change observers have started --- test/github-package.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/github-package.test.js b/test/github-package.test.js index de8f34174f..d52d5a46e2 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -411,7 +411,12 @@ describe('GithubPackage', function() { project.setPaths([workdirPath1, workdirPath2]); await githubPackage.activate(); - await untilRepositories(workdirPath1, workdirPath2); + await until('change observers have started', async () => { + const observers = await Promise.all([workdirPath1, workdirPath2].map(workdir => { + return contextPool.getContext(workdir).getChangeObserverPromise(); + })); + return observers.every(observer => observer && observer.started); + }); [atomGitRepository1, atomGitRepository2] = githubPackage.project.getRepositories(); sinon.stub(atomGitRepository1, 'refreshStatus'); From c29ac8c8e79b2942f5f24aa2dcb44162a292fa32 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 13:12:18 -0400 Subject: [PATCH 048/161] Set .started *after* watcher has been started --- lib/models/file-system-change-observer.js | 2 +- lib/models/workspace-change-observer.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/models/file-system-change-observer.js b/lib/models/file-system-change-observer.js index e87af38d92..870a904048 100644 --- a/lib/models/file-system-change-observer.js +++ b/lib/models/file-system-change-observer.js @@ -13,8 +13,8 @@ export default class FileSystemChangeObserver { } async start() { - this.started = true; await this.watchRepository(); + this.started = true; return this; } diff --git a/lib/models/workspace-change-observer.js b/lib/models/workspace-change-observer.js index ef6f41c083..12bf99d0b9 100644 --- a/lib/models/workspace-change-observer.js +++ b/lib/models/workspace-change-observer.js @@ -16,7 +16,6 @@ export default class WorkspaceChangeObserver { } async start() { - this.started = true; const focusHandler = event => { if (this.repository) { this.logger.showEvents([event]); @@ -30,6 +29,7 @@ export default class WorkspaceChangeObserver { new Disposable(() => this.window.removeEventListener('focus', focusHandler)), ); await this.watchActiveRepositoryGitDirectory(); + this.started = true; return this; } From a1d8522d2a81a3699a12e97de500cadd112f9b29 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 13:15:33 -0400 Subject: [PATCH 049/161] CompositeDisposable.add() no longer supports multiple arguments --- lib/models/workspace-change-observer.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/models/workspace-change-observer.js b/lib/models/workspace-change-observer.js index 12bf99d0b9..9e5c5d9ce9 100644 --- a/lib/models/workspace-change-observer.js +++ b/lib/models/workspace-change-observer.js @@ -23,8 +23,7 @@ export default class WorkspaceChangeObserver { } }; this.window.addEventListener('focus', focusHandler); - this.disposables = new CompositeDisposable(); - this.disposables.add( + this.disposables = new CompositeDisposable( this.workspace.observeTextEditors(this.observeTextEditor), new Disposable(() => this.window.removeEventListener('focus', focusHandler)), ); From 9e40126e9ab385de654b74129f29d5371b18bfe9 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 13:34:00 -0400 Subject: [PATCH 050/161] Initialize disposables in the constructor --- lib/models/workspace-change-observer.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/models/workspace-change-observer.js b/lib/models/workspace-change-observer.js index 9e5c5d9ce9..2b3a0ac4fa 100644 --- a/lib/models/workspace-change-observer.js +++ b/lib/models/workspace-change-observer.js @@ -12,6 +12,7 @@ export default class WorkspaceChangeObserver { this.workspace = workspace; this.observedBuffers = new WeakSet(); this.emitter = new Emitter(); + this.disposables = new CompositeDisposable(); this.logger = new EventLogger('WorkspaceChangeObserver'); } @@ -23,7 +24,7 @@ export default class WorkspaceChangeObserver { } }; this.window.addEventListener('focus', focusHandler); - this.disposables = new CompositeDisposable( + this.disposables.add( this.workspace.observeTextEditors(this.observeTextEditor), new Disposable(() => this.window.removeEventListener('focus', focusHandler)), ); From b7866ce86c775187fc63eabbcd9c32098ca9bfba Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 14:11:46 -0400 Subject: [PATCH 051/161] Return a Disposable from stubbed method --- test/models/workdir-context.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/models/workdir-context.test.js b/test/models/workdir-context.test.js index 3ae8c42223..5066389650 100644 --- a/test/models/workdir-context.test.js +++ b/test/models/workdir-context.test.js @@ -1,4 +1,4 @@ -import {CompositeDisposable} from 'event-kit'; +import {CompositeDisposable, Disposable} from 'event-kit'; import {cloneRepository} from '../helpers'; @@ -19,7 +19,7 @@ describe('WorkdirContext', function() { }; mockWorkspace = { - observeTextEditors: sinon.spy(), + observeTextEditors: sinon.stub().returns(new Disposable()), }; mockPromptCallback = query => 'reply'; From 6bc97149138e0c9567649f4109bb1b2ad2236313 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 14:20:21 -0400 Subject: [PATCH 052/161] Fully initialize workspace watchers on Linux --- test/models/workdir-context.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/models/workdir-context.test.js b/test/models/workdir-context.test.js index 5066389650..34546d689d 100644 --- a/test/models/workdir-context.test.js +++ b/test/models/workdir-context.test.js @@ -149,7 +149,12 @@ describe('WorkdirContext', function() { beforeEach(async function() { repository = await Repository.open(workingDirectory); - context = new WorkdirContext(workingDirectory, {repository}); + context = new WorkdirContext(workingDirectory, { + repository, + window: mockWindow, + workspace: mockWorkspace, + promptCallback: mockPromptCallback, + }); }); it('returns the repository synchrounsly', function() { From c0f279e6ec3ced64e3fe0e7945863d5bf3256d9e Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 11 Apr 2017 14:35:16 -0400 Subject: [PATCH 053/161] Instrument context changes and full re-renders --- lib/github-package.js | 29 ++++++++++++++++++++++++++--- lib/switchboard.js | 4 ++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 51e9275009..0071bfdd86 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -69,6 +69,7 @@ export default class GithubPackage { const stagingSeries = ['stageLine', 'stageHunk', 'unstageLine', 'unstageHunk']; this.subscriptions.add( + // Staging and unstaging operations this.switchboard.onDidBeginStageOperation(payload => { if (payload.stage && payload.line) { yardstick.begin('stageLine'); @@ -88,6 +89,20 @@ export default class GithubPackage { yardstick.finish(stagingSeries); } }), + + // Active context changes + this.switchboard.onDidScheduleActiveContextUpdate(() => { + yardstick.begin('activeContextChange'); + }), + this.switchboard.onDidBeginActiveContextUpdate(() => { + yardstick.mark('activeContextChange', 'queue-wait'); + }), + this.switchboard.onDidFinishContextChangeRender(() => { + yardstick.mark('activeContextChange', 'render'); + }), + this.switchboard.onDidFinishActiveContextUpdate(() => { + yardstick.finish('activeContextChange'); + }), ); } @@ -148,7 +163,7 @@ export default class GithubPackage { } @autobind - rerender() { + rerender(callback) { if (!this.element) { this.element = document.createElement('div'); this.subscriptions.add(new Disposable(() => { @@ -174,7 +189,7 @@ export default class GithubPackage { createRepositoryForProjectPath={this.createRepositoryForProjectPath} cloneRepositoryForProjectPath={this.cloneRepositoryForProjectPath} switchboard={this.switchboard} - />, this.element, + />, this.element, callback, ); } @@ -242,6 +257,7 @@ export default class GithubPackage { @autobind async scheduleActiveContextUpdate(savedState = {}) { + this.switchboard.didScheduleActiveContextUpdate(); await this.activeContextQueue.push(this.updateActiveContext.bind(this, savedState), {parallel: false}); } @@ -298,11 +314,18 @@ export default class GithubPackage { setActiveContext(nextActiveContext) { if (nextActiveContext !== this.activeContext) { this.activeContext = nextActiveContext; - this.rerender(); + this.rerender(() => { + this.switchboard.didFinishContextChangeRender(); + this.switchboard.didFinishActiveContextUpdate(); + }); + } else { + this.switchboard.didFinishActiveContextUpdate(); } } async updateActiveContext(savedState = {}) { + this.switchboard.didBeginActiveContextUpdate(); + const nextActiveContext = await this.getNextContext(savedState); this.setActiveContext(nextActiveContext); } diff --git a/lib/switchboard.js b/lib/switchboard.js index a00cb14c6c..45a66603b6 100644 --- a/lib/switchboard.js +++ b/lib/switchboard.js @@ -58,7 +58,11 @@ export default class Switchboard { 'BeginStageOperation', 'FinishStageOperation', 'ChangePatch', + 'ScheduleActiveContextUpdate', + 'BeginActiveContextUpdate', + 'FinishActiveContextUpdate', 'FinishRender', + 'FinishContextChangeRender', ].forEach(eventName => { Switchboard.prototype[`did${eventName}`] = function(payload) { this.did(eventName, payload); From e67fe5cfddc2f2deaa84dcf98bbdc747756ae6a0 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 12 Apr 2017 11:48:34 -0400 Subject: [PATCH 054/161] :fire: unused method --- lib/models/repository.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/models/repository.js b/lib/models/repository.js index 6b4a0dc137..ab0f05cd0b 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -355,10 +355,6 @@ export default class Repository { } } - getMergeHead() { - return this.git.getMergeHead(); - } - async getMergeMessage() { try { const contents = await readFile(path.join(this.getWorkingDirectoryPath(), '.git', 'MERGE_MSG'), 'utf8'); From 5b30c7ac56f3bcfa5e6fb3cc19b3244bff13be0b Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 12 Apr 2017 11:49:47 -0400 Subject: [PATCH 055/161] Rework Repository tests in anticipation of the state machine --- test/models/repository.test.js | 182 ++++++++++++++++++++------------- 1 file changed, 109 insertions(+), 73 deletions(-) diff --git a/test/models/repository.test.js b/test/models/repository.test.js index 70d1b03441..ab69c46ae1 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -5,7 +5,7 @@ import temp from 'temp'; import Repository from '../../lib/models/repository'; -import {cloneRepository, buildRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories, getHeadCommitOnRemote, assertEqualSortedArraysByKey} from '../helpers'; +import {cloneRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories, getHeadCommitOnRemote, assertEqualSortedArraysByKey} from '../helpers'; describe('Repository', function() { describe('githubInfoFromRemote', function() { @@ -43,8 +43,17 @@ describe('Repository', function() { describe('init', function() { it('creates a repository in the given dir and returns the repository', async function() { const soonToBeRepositoryPath = fs.realpathSync(temp.mkdirSync()); - const repo = await Repository.init(soonToBeRepositoryPath); - assert.isTrue(await repo.isGitRepository()); + const repo = new Repository(soonToBeRepositoryPath); + assert.isTrue(repo.isLoading()); + + await repo.getLoadPromise(); + assert.isTrue(repo.isEmpty()); + + const initPromise = repo.init(); + assert.isTrue(repo.isLoading()); + await initPromise; + + assert.isTrue(repo.isPresent()); assert.equal(repo.getWorkingDirectoryPath(), soonToBeRepositoryPath); }); }); @@ -54,8 +63,11 @@ describe('Repository', function() { const upstreamPath = await cloneRepository('three-files'); const destDir = fs.realpathSync(temp.mkdirSync()); - const repo = await Repository.clone(upstreamPath, destDir); - assert.isTrue(await repo.isGitRepository()); + const repo = new Repository(destDir); + const clonePromise = repo.clone(upstreamPath, destDir); + assert.isTrue(repo.isLoading()); + await clonePromise; + assert.isTrue(repo.isPresent()); assert.equal(repo.getWorkingDirectoryPath(), destDir); }); @@ -64,8 +76,9 @@ describe('Repository', function() { const parentDir = fs.realpathSync(temp.mkdirSync()); const destDir = path.join(parentDir, 'subdir'); - const repo = await Repository.clone(upstreamPath, destDir); - assert.isTrue(await repo.isGitRepository()); + const repo = new Repository(destDir); + await repo.clone(upstreamPath, destDir); + assert.isTrue(repo.isPresent()); assert.equal(repo.getWorkingDirectoryPath(), destDir); }); }); @@ -73,7 +86,9 @@ describe('Repository', function() { describe('staging and unstaging files', function() { it('can stage and unstage modified files', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + fs.writeFileSync(path.join(workingDirPath, 'subdir-1', 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); const [patch] = await repo.getUnstagedChanges(); const filePath = patch.filePath; @@ -91,7 +106,9 @@ describe('Repository', function() { it('can stage and unstage removed files', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + fs.unlinkSync(path.join(workingDirPath, 'subdir-1', 'b.txt')); const [patch] = await repo.getUnstagedChanges(); const filePath = patch.filePath; @@ -109,7 +126,9 @@ describe('Repository', function() { it('can stage and unstage renamed files', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + fs.renameSync(path.join(workingDirPath, 'c.txt'), path.join(workingDirPath, 'subdir-1', 'd.txt')); const patches = await repo.getUnstagedChanges(); const filePath1 = patches[0].filePath; @@ -129,7 +148,9 @@ describe('Repository', function() { it('can stage and unstage added files', async function() { const workingDirPath = await cloneRepository('three-files'); fs.writeFileSync(path.join(workingDirPath, 'subdir-1', 'e.txt'), 'qux', 'utf8'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + const [patch] = await repo.getUnstagedChanges(); const filePath = patch.filePath; @@ -146,7 +167,9 @@ describe('Repository', function() { it('can unstage and retrieve staged changes relative to HEAD~', async function() { const workingDirPath = await cloneRepository('multiple-commits'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + fs.writeFileSync(path.join(workingDirPath, 'file.txt'), 'three\nfour\n', 'utf8'); assert.deepEqual(await repo.getStagedChangesSinceParentCommit(), [ { @@ -194,7 +217,9 @@ describe('Repository', function() { describe('getFilePatchForPath', function() { it('returns cached FilePatch objects if they exist', async function() { const workingDirPath = await cloneRepository('multiple-commits'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'foooooo', 'utf8'); fs.writeFileSync(path.join(workingDirPath, 'file.txt'), 'qux\nfoo\nbar\n', 'utf8'); await repo.stageFiles(['file.txt']); @@ -209,7 +234,9 @@ describe('Repository', function() { it('returns new FilePatch object after repository refresh', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); const filePatchA = await repo.getFilePatchForPath('a.txt'); @@ -224,7 +251,9 @@ describe('Repository', function() { describe('applyPatchToIndex', function() { it('can stage and unstage modified files', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + fs.writeFileSync(path.join(workingDirPath, 'subdir-1', 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); const unstagedPatch1 = await repo.getFilePatchForPath(path.join('subdir-1', 'a.txt')); @@ -256,7 +285,9 @@ describe('Repository', function() { describe('commit', function() { it('creates a commit that contains the staged changes', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + assert.equal((await repo.getLastCommit()).message, 'Initial commit'); fs.writeFileSync(path.join(workingDirPath, 'subdir-1', 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); @@ -282,7 +313,9 @@ describe('Repository', function() { it('amends the last commit when the amend option is set to true', async function() { const workingDirPath = await cloneRepository('multiple-commits'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + const lastCommit = await repo.git.getHeadCommit(); const lastCommitParent = await repo.git.getCommit('HEAD~'); await repo.commit('amend last commit', {amend: true, allowEmpty: true}); @@ -294,7 +327,9 @@ describe('Repository', function() { it('throws an error when there are unmerged files', async function() { const workingDirPath = await cloneRepository('merge-conflict'); - const repository = await buildRepository(workingDirPath); + const repository = new Repository(workingDirPath); + await repository.getLoadPromise(); + try { await repository.git.merge('origin/branch'); } catch (e) { @@ -317,7 +352,8 @@ describe('Repository', function() { it('wraps the commit message body at 72 characters', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); fs.writeFileSync(path.join(workingDirPath, 'subdir-1', 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); await repo.stageFiles([path.join('subdir-1', 'a.txt')]); @@ -338,7 +374,8 @@ describe('Repository', function() { it('strips out comments', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); fs.writeFileSync(path.join(workingDirPath, 'subdir-1', 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); await repo.stageFiles([path.join('subdir-1', 'a.txt')]); @@ -357,7 +394,9 @@ describe('Repository', function() { describe('fetch(branchName)', function() { it('brings commits from the remote and updates remote branch, and does not update branch', async function() { const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); - const localRepo = await buildRepository(localRepoPath); + const localRepo = new Repository(localRepoPath); + await localRepo.getLoadPromise(); + let remoteHead, localHead; remoteHead = await localRepo.git.getCommit('origin/master'); localHead = await localRepo.git.getCommit('master'); @@ -375,7 +414,9 @@ describe('Repository', function() { describe('pull()', function() { it('updates the remote branch and merges into local branch', async function() { const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); - const localRepo = await buildRepository(localRepoPath); + const localRepo = new Repository(localRepoPath); + await localRepo.getLoadPromise(); + let remoteHead, localHead; remoteHead = await localRepo.git.getCommit('origin/master'); localHead = await localRepo.git.getCommit('master'); @@ -393,7 +434,8 @@ describe('Repository', function() { describe('push()', function() { it('sends commits to the remote and updates ', async function() { const {localRepoPath, remoteRepoPath} = await setUpLocalAndRemoteRepositories(); - const localRepo = await buildRepository(localRepoPath); + const localRepo = new Repository(localRepoPath); + await localRepo.getLoadPromise(); let localHead, localRemoteHead, remoteHead; localHead = await localRepo.git.getCommit('master'); @@ -422,7 +464,8 @@ describe('Repository', function() { describe('getAheadCount(branchName) and getBehindCount(branchName)', function() { it('returns the number of commits ahead and behind the remote', async function() { const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); - const localRepo = await buildRepository(localRepoPath); + const localRepo = new Repository(localRepoPath); + await localRepo.getLoadPromise(); assert.equal(await localRepo.getBehindCount('master'), 0); assert.equal(await localRepo.getAheadCount('master'), 0); @@ -439,7 +482,8 @@ describe('Repository', function() { describe('getRemoteForBranch(branchName)', function() { it('returns the remote name associated to the supplied branch name, null if none exists', async function() { const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); - const localRepo = await buildRepository(localRepoPath); + const localRepo = new Repository(localRepoPath); + await localRepo.getLoadPromise(); assert.equal(await localRepo.getRemoteForBranch('master'), 'origin'); await localRepo.git.exec(['remote', 'rename', 'origin', 'foo']); @@ -454,15 +498,9 @@ describe('Repository', function() { describe('getMergeConflicts()', function() { it('returns a promise resolving to an array of MergeConflict objects', async function() { const workingDirPath = await cloneRepository('merge-conflict'); - const repo = await buildRepository(workingDirPath); - try { - await repo.git.merge('origin/branch'); - } catch (e) { - // expected - if (!e.message.match(/CONFLICT/)) { - throw new Error(`merge failed for wrong reason: ${e.message}`); - } - } + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + await assert.isRejected(repo.git.merge('origin/branch'), /CONFLICT/); repo.refresh(); let mergeConflicts = await repo.getMergeConflicts(); @@ -520,7 +558,8 @@ describe('Repository', function() { it('returns an empty array if the repo has no merge conflicts', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); const mergeConflicts = await repo.getMergeConflicts(); assert.deepEqual(mergeConflicts, []); @@ -530,12 +569,9 @@ describe('Repository', function() { describe('stageFiles([path])', function() { it('updates the staged changes accordingly', async function() { const workingDirPath = await cloneRepository('merge-conflict'); - const repo = await buildRepository(workingDirPath); - try { - await repo.git.merge('origin/branch'); - } catch (e) { - // expected - } + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + await assert.isRejected(repo.git.merge('origin/branch')); repo.refresh(); const mergeConflictPaths = (await repo.getMergeConflicts()).map(c => c.filePath); @@ -584,12 +620,9 @@ describe('Repository', function() { describe('pathHasMergeMarkers()', function() { it('returns true if and only if the file has merge markers', async function() { const workingDirPath = await cloneRepository('merge-conflict'); - const repo = await buildRepository(workingDirPath); - try { - await repo.git.merge('origin/branch'); - } catch (e) { - // expected - } + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + await assert.isRejected(repo.git.merge('origin/branch')); assert.isTrue(await repo.pathHasMergeMarkers('added-to-both.txt')); assert.isFalse(await repo.pathHasMergeMarkers('removed-on-master.txt')); @@ -625,12 +658,11 @@ describe('Repository', function() { describe('when the working directory is clean', function() { it('resets the index and the working directory to match HEAD', async function() { const workingDirPath = await cloneRepository('merge-conflict-abort'); - const repo = await buildRepository(workingDirPath); - try { - await repo.git.merge('origin/spanish'); - } catch (e) { - // expected - } + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + + await assert.isRejected(repo.git.merge('origin/spanish')); + assert.equal(await repo.isMerging(), true); await repo.abortMerge(); assert.equal(await repo.isMerging(), false); @@ -640,12 +672,9 @@ describe('Repository', function() { describe('when a dirty file in the working directory is NOT under conflict', function() { it('successfully aborts the merge and does not affect the dirty file', async function() { const workingDirPath = await cloneRepository('merge-conflict-abort'); - const repo = await buildRepository(workingDirPath); - try { - await repo.git.merge('origin/spanish'); - } catch (e) { - // expected - } + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + await assert.isRejected(repo.git.merge('origin/spanish')); fs.writeFileSync(path.join(workingDirPath, 'fruit.txt'), 'a change\n'); assert.equal(await repo.isMerging(), true); @@ -661,12 +690,9 @@ describe('Repository', function() { describe('when a dirty file in the working directory is under conflict', function() { it('throws an error indicating that the abort could not be completed', async function() { const workingDirPath = await cloneRepository('merge-conflict-abort'); - const repo = await buildRepository(workingDirPath); - try { - await repo.git.merge('origin/spanish'); - } catch (e) { - // expected - } + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + await assert.isRejected(repo.git.merge('origin/spanish')); fs.writeFileSync(path.join(workingDirPath, 'animal.txt'), 'a change\n'); const stagedChanges = await repo.getStagedChanges(); @@ -674,7 +700,7 @@ describe('Repository', function() { assert.equal(await repo.isMerging(), true); try { - await repo.abortMerge(); + await assert.isRejected(repo.abortMerge(), /^git merge --abort/); assert(false); } catch (e) { assert.match(e.command, /^git merge --abort/); @@ -690,7 +716,9 @@ describe('Repository', function() { describe('discardWorkDirChangesForPaths()', function() { it('can discard working directory changes in modified files', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + fs.writeFileSync(path.join(workingDirPath, 'subdir-1', 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); fs.writeFileSync(path.join(workingDirPath, 'subdir-1', 'b.txt'), 'qux\nfoo\nbar\n', 'utf8'); fs.writeFileSync(path.join(workingDirPath, 'new-file.txt'), 'hello there', 'utf8'); @@ -704,7 +732,9 @@ describe('Repository', function() { it('can discard working directory changes in removed files', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + fs.unlinkSync(path.join(workingDirPath, 'subdir-1', 'a.txt')); fs.unlinkSync(path.join(workingDirPath, 'subdir-1', 'b.txt')); const unstagedChanges = await repo.getUnstagedChanges(); @@ -717,7 +747,9 @@ describe('Repository', function() { it('can discard working directory changes added files', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo = await buildRepository(workingDirPath); + const repo = new Repository(workingDirPath); + await repo.getLoadPromise(); + fs.writeFileSync(path.join(workingDirPath, 'subdir-1', 'e.txt'), 'qux', 'utf8'); fs.writeFileSync(path.join(workingDirPath, 'subdir-1', 'f.txt'), 'qux', 'utf8'); const unstagedChanges = await repo.getUnstagedChanges(); @@ -732,7 +764,8 @@ describe('Repository', function() { describe('maintaining discard history across repository instances', function() { it('restores the history', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo1 = await buildRepository(workingDirPath); + const repo1 = new Repository(workingDirPath); + await repo1.getLoadPromise(); fs.writeFileSync(path.join(workingDirPath, 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); fs.writeFileSync(path.join(workingDirPath, 'b.txt'), 'woohoo', 'utf8'); @@ -748,7 +781,8 @@ describe('Repository', function() { }); const repo1HistorySha = repo1.createDiscardHistoryBlob(); - const repo2 = await buildRepository(workingDirPath); + const repo2 = new Repository(workingDirPath); + await repo2.getLoadPromise(); const repo2HistorySha = repo2.createDiscardHistoryBlob(); assert.deepEqual(repo2HistorySha, repo1HistorySha); @@ -756,14 +790,16 @@ describe('Repository', function() { it('is resilient to missing history blobs', async function() { const workingDirPath = await cloneRepository('three-files'); - const repo1 = await buildRepository(workingDirPath); + const repo1 = new Repository(workingDirPath); + await repo1.getLoadPromise(); await repo1.setConfig('atomGithub.historySha', '1111111111111111111111111111111111111111'); // Should not throw await repo1.updateDiscardHistory(); // Also should not throw - await buildRepository(workingDirPath); + const repo2 = new Repository(workingDirPath); + await repo2.getLoadPromise(); }); }); }); From 4a5a6e771425078bb2c0780bbb68fc2b195ea62e Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 12 Apr 2017 11:50:27 -0400 Subject: [PATCH 056/161] Additional models to return from Repository methods --- lib/models/branch.js | 23 ++++++++++++++++++++++ lib/models/commit.js | 47 ++++++++++++++++++++++++++++++++++++++++++++ lib/models/remote.js | 23 ++++++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 lib/models/branch.js create mode 100644 lib/models/commit.js create mode 100644 lib/models/remote.js diff --git a/lib/models/branch.js b/lib/models/branch.js new file mode 100644 index 0000000000..9047086b5b --- /dev/null +++ b/lib/models/branch.js @@ -0,0 +1,23 @@ +export default class Branch { + constructor(name) { + this.name = name; + } + + getName() { + return this.name; + } + + isPresent() { + return true; + } +} + +export const nullBranch = { + getName() { + return ''; + }, + + isPresent() { + return false; + }, +}; diff --git a/lib/models/commit.js b/lib/models/commit.js new file mode 100644 index 0000000000..f7398c5a8c --- /dev/null +++ b/lib/models/commit.js @@ -0,0 +1,47 @@ +const UNBORN = {}; + +export default class Commit { + static createUnborn() { + return new Commit('', '', UNBORN); + } + + constructor(sha, message, unbornRef = null) { + this.sha = sha; + this.message = message.trim(); + this.unbornRef = unbornRef === UNBORN; + } + + getSha() { + return this.sha; + } + + getMessage() { + return this.message; + } + + isUnbornRef() { + return this.unbornRef; + } + + isPresent() { + return true; + } +} + +export const nullCommit = { + getSha() { + return ''; + }, + + getMessage() { + return ''; + }, + + isUnbornRef() { + return false; + }, + + isPresent() { + return false; + }, +}; diff --git a/lib/models/remote.js b/lib/models/remote.js new file mode 100644 index 0000000000..05d237faa9 --- /dev/null +++ b/lib/models/remote.js @@ -0,0 +1,23 @@ +export default class Remote { + constructor(name) { + this.name = name; + } + + getName() { + return this.name; + } + + isPresent() { + return true; + } +} + +export const nullRemote = { + getName() { + return ''; + }, + + isPresent() { + return false; + }, +}; From 8cd8c5b4f2b3dc40b64672a0f1ef8c52ae6c7e07 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 13 Apr 2017 09:28:37 -0400 Subject: [PATCH 057/161] `mkdirs` helper to promisify `fs.mkdirs` --- lib/helpers.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/helpers.js b/lib/helpers.js index 9cdbdd5b41..c43a51943d 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -170,6 +170,12 @@ export async function isFileExecutable(absoluteFilePath) { return stat.mode & fs.constants.S_IXUSR; // eslint-disable-line no-bitwise } +export function mkdirs(directory) { + return new Promise((resolve, reject) => { + fs.mkdirs(directory, err => (err ? reject(err) : resolve())); + }); +} + export function shortenSha(sha) { return sha.slice(0, 8); } From 8ff58fa13eedb68b9c724c6a269d0448aeeb9db3 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 13 Apr 2017 09:29:13 -0400 Subject: [PATCH 058/161] Test case to verify State delegates --- test/models/repository.test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/models/repository.test.js b/test/models/repository.test.js index ab69c46ae1..b838d1f8db 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -2,12 +2,31 @@ import fs from 'fs'; import path from 'path'; import dedent from 'dedent-js'; import temp from 'temp'; +import util from 'util'; import Repository from '../../lib/models/repository'; +import {expectedDelegates} from '../../lib/models/repository-states'; import {cloneRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories, getHeadCommitOnRemote, assertEqualSortedArraysByKey} from '../helpers'; describe('Repository', function() { + it('delegates all state methods', function() { + const missing = expectedDelegates.filter(delegateName => { + return Repository.prototype[delegateName] === undefined; + }); + + // For convenience, write the delegate list to the console when there are any missing (in addition to failing the + // test.) + if (missing.length > 0) { + const formatted = util.inspect(expectedDelegates); + + // eslint-disable-next-line no-console + console.log(`Expected delegates for your copy-and-paste convenience:\n\n---\n${formatted}\n---\n`); + } + + assert.lengthOf(missing, 0); + }); + describe('githubInfoFromRemote', function() { it('returns info about a GitHub repo based on the remote URL', function() { const atomRepo = { From 862d60e895440dbd19a3bf320a43e3e27f86a5b8 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 13 Apr 2017 09:29:40 -0400 Subject: [PATCH 059/161] `.clone()` takes one argument --- test/models/repository.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/models/repository.test.js b/test/models/repository.test.js index b838d1f8db..c48e8bae8c 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -83,7 +83,7 @@ describe('Repository', function() { const destDir = fs.realpathSync(temp.mkdirSync()); const repo = new Repository(destDir); - const clonePromise = repo.clone(upstreamPath, destDir); + const clonePromise = repo.clone(upstreamPath); assert.isTrue(repo.isLoading()); await clonePromise; assert.isTrue(repo.isPresent()); From be7672dde41e3e4ee8f08883e795e0140662024d Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 13 Apr 2017 09:30:10 -0400 Subject: [PATCH 060/161] Delegate ALL THE THINGS to the current State --- lib/models/repository.js | 563 +++++++++------------------------------ 1 file changed, 126 insertions(+), 437 deletions(-) diff --git a/lib/models/repository.js b/lib/models/repository.js index ab0f05cd0b..5eb8ff21b5 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -1,54 +1,13 @@ -import fs from 'fs-extra'; import path from 'path'; -import {Emitter, Disposable} from 'event-kit'; -import {autobind} from 'core-decorators'; +import {Emitter} from 'event-kit'; import CompositeGitStrategy from '../composite-git-strategy'; -import FilePatch from './file-patch'; -import Hunk from './hunk'; -import HunkLine from './hunk-line'; -import DiscardHistory from './discard-history'; -import {readFile, deleteFileOrFolder} from '../helpers'; +import {readFile} from '../helpers'; -const MERGE_MARKER_REGEX = /^(>|<){7} \S+$/m; - -function partition(array, predicate) { - const matches = []; - const nonmatches = []; - array.forEach(item => { - if (predicate(item)) { - matches.push(item); - } else { - nonmatches.push(item); - } - }); - return [matches, nonmatches]; -} - -async function loadHistoryPayload(git) { - const historySha = await git.getConfig('atomGithub.historySha'); - if (!historySha) { - return {}; - } - - let blob; - try { - blob = await git.getBlobContents(historySha); - } catch (e) { - if (/Not a valid object name/.test(e.stdErr)) { - return {}; - } - - throw e; - } +import {InitialState} from './repository-states'; - try { - return JSON.parse(blob); - } catch (e) { - return {}; - } -} +const MERGE_MARKER_REGEX = /^(>|<){7} \S+$/m; export class AbortMergeError extends Error { constructor(code, filePath) { @@ -70,32 +29,6 @@ export class CommitError extends Error { } export default class Repository { - static async open(workingDirectoryPath, gitStrategy) { - const strategy = gitStrategy || CompositeGitStrategy.create(workingDirectoryPath); - if (await strategy.isGitRepository()) { - const history = await loadHistoryPayload(strategy); - return new Repository(workingDirectoryPath, strategy, history); - } else { - return null; - } - } - - static async init(workingDirectoryPath, gitStrategy) { - const strategy = gitStrategy || CompositeGitStrategy.create(workingDirectoryPath); - await strategy.init(workingDirectoryPath); - return this.open(workingDirectoryPath, strategy); - } - - static async clone(remoteUrl, destDirectory, gitStrategy) { - await new Promise((resolve, reject) => { - fs.mkdirs(destDirectory, err => (err ? reject(err) : resolve())); - }); - - const strategy = gitStrategy || CompositeGitStrategy.create(destDirectory); - await strategy.clone(remoteUrl, {recursive: true}); - return this.open(destDirectory, strategy); - } - static githubInfoFromRemote(remote) { // proto login domain owner repo const regex = /(?:.+:\/\/)?(?:.+@)?github\.com[:/]([^/]+)\/(.+)/; @@ -115,236 +48,68 @@ export default class Repository { } } - constructor(workingDirectoryPath, gitStrategy, history) { + constructor(workingDirectoryPath, gitStrategy = null) { this.workingDirectoryPath = workingDirectoryPath; - this.emitter = new Emitter(); - this.stagedFilePatchesByPath = new Map(); - this.stagedFilePatchesSinceParentCommitByPath = new Map(); - this.unstagedFilePatchesByPath = new Map(); - this.git = gitStrategy; - this.destroyed = false; - this.discardHistory = new DiscardHistory( - this.createBlob, - this.expandBlobToFile, - this.mergeFile, - this.workingDirectoryPath, - {maxHistoryLength: 60}, - ); - if (history) { this.discardHistory.updateHistory(history); } - const onWindowFocus = () => this.updateDiscardHistory(); - window.addEventListener('focus', onWindowFocus); - this.windowFocusDisposable = new Disposable(() => window.removeEventListener('focus', onWindowFocus)); - } - - /* - * Use `callback` to request user input from all git strategies. - */ - setPromptCallback(callback) { - this.git.getImplementers().forEach(strategy => strategy.setPromptCallback(callback)); - } - - destroy() { - this.destroyed = true; - this.emitter.emit('did-destroy'); - this.emitter.dispose(); - this.windowFocusDisposable.dispose(); - } + this.git = gitStrategy || CompositeGitStrategy.create(workingDirectoryPath); - isDestroyed() { - return this.destroyed; - } - - onDidDestroy(callback) { - return this.emitter.on('did-destroy', callback); - } - - onDidUpdate(callback) { - return this.emitter.on('did-update', callback); - } - - didUpdate() { - this.emitter.emit('did-update'); - } - - getWorkingDirectoryPath() { - return this.workingDirectoryPath; - } - - getGitDirectoryPath() { - return path.join(this.getWorkingDirectoryPath(), '.git'); - } - - refresh() { - /* eslint-disable no-console */ - this.clearCachedFilePatches(); - this.fileStatusesPromise = null; - this.stagedChangesSinceParentCommitPromise = null; - this.didUpdate(); - /* eslint-enable no-console */ - } - - isGitRepository() { - return this.git.isGitRepository(); - } - - fetchStatusesForChangedFiles() { - return this.git.getStatusesForChangedFiles(); - } - - getStatusesForChangedFiles() { - if (!this.fileStatusesPromise) { - this.fileStatusesPromise = this.fetchStatusesForChangedFiles(); - } - return this.fileStatusesPromise; - } - - async getUnstagedChanges() { - const {unstagedFiles} = await this.getStatusesForChangedFiles(); - return Object.keys(unstagedFiles).map(filePath => { return {filePath, status: unstagedFiles[filePath]}; }); - } - - async getStagedChanges() { - const {stagedFiles} = await this.getStatusesForChangedFiles(); - return Object.keys(stagedFiles).map(filePath => { return {filePath, status: stagedFiles[filePath]}; }); - } + this.emitter = new Emitter(); - async getMergeConflicts() { - const {mergeConflictFiles} = await this.getStatusesForChangedFiles(); - return Object.keys(mergeConflictFiles).map(filePath => { - return {filePath, status: mergeConflictFiles[filePath]}; + this.loadPromise = new Promise(resolve => { + const sub = this.onDidChangeState(() => { + if (!this.isLoading()) { + resolve(); + sub.dispose(); + } else if (this.isDestroyed()) { + sub.dispose(); + } + }); }); - } - - async fetchStagedChangesSinceParentCommit() { - try { - const stagedFiles = await this.git.diffFileStatus({staged: true, target: 'HEAD~'}); - return Object.keys(stagedFiles).map(filePath => { return {filePath, status: stagedFiles[filePath]}; }); - } catch (e) { - if (e.message.includes('ambiguous argument \'HEAD~\'')) { - return []; - } else { - throw e; - } - } - } - getStagedChangesSinceParentCommit() { - if (!this.stagedChangesSinceParentCommitPromise) { - this.stagedChangesSinceParentCommitPromise = this.fetchStagedChangesSinceParentCommit(); - } - return this.stagedChangesSinceParentCommitPromise; + this.transitionTo(InitialState); } - clearCachedFilePatches() { - this.stagedFilePatchesByPath = new Map(); - this.stagedFilePatchesSinceParentCommitByPath = new Map(); - this.unstagedFilePatchesByPath = new Map(); - } + // State management ////////////////////////////////////////////////////////////////////////////////////////////////// - isPartiallyStaged(filePath) { - return this.git.isPartiallyStaged(filePath); - } + transition(currentState, StateConstructor, ...payload) { + const nextState = new StateConstructor(this, ...payload); + this.state = nextState; - getCachedFilePatchForPath(filePath, {staged, amending} = {}) { - if (staged && amending) { - return this.stagedFilePatchesSinceParentCommitByPath.get(filePath); - } else if (staged) { - return this.stagedFilePatchesByPath.get(filePath); - } else { - return this.unstagedFilePatchesByPath.get(filePath); - } - } + this.emitter.emit('did-change-state', {from: currentState, to: this.state}); - cacheFilePatchForPath(filePath, filePatch, {staged, amending} = {}) { - if (staged && amending) { - this.stagedFilePatchesSinceParentCommitByPath.set(filePath, filePatch); - } else if (staged) { - this.stagedFilePatchesByPath.set(filePath, filePatch); - } else { - this.unstagedFilePatchesByPath.set(filePath, filePatch); - } + return this.state.start(); } - async getFilePatchForPath(filePath, options = {}) { - const cachedFilePatch = this.getCachedFilePatchForPath(filePath, options); - if (cachedFilePatch) { return cachedFilePatch; } - - if (options.amending) { options.baseCommit = 'HEAD~'; } - const rawDiff = await this.git.getDiffForFilePath(filePath, options); - if (rawDiff) { - const [filePatch] = this.buildFilePatchesFromRawDiffs([rawDiff]); - this.cacheFilePatchForPath(filePath, filePatch, options); - return filePatch; - } else { - return null; - } + transitionTo(StateConstructor, ...payload) { + return this.transition(this.state, StateConstructor, ...payload); } - buildFilePatchesFromRawDiffs(rawDiffs) { - let diffLineNumber = 0; - return rawDiffs.map(patch => { - const hunks = patch.hunks.map(hunk => { - let oldLineNumber = hunk.oldStartLine; - let newLineNumber = hunk.newStartLine; - const hunkLines = hunk.lines.map(line => { - const status = HunkLine.statusMap[line[0]]; - const text = line.slice(1); - let hunkLine; - if (status === 'unchanged') { - hunkLine = new HunkLine(text, status, oldLineNumber, newLineNumber, diffLineNumber++); - oldLineNumber++; - newLineNumber++; - } else if (status === 'added') { - hunkLine = new HunkLine(text, status, -1, newLineNumber, diffLineNumber++); - newLineNumber++; - } else if (status === 'deleted') { - hunkLine = new HunkLine(text, status, oldLineNumber, -1, diffLineNumber++); - oldLineNumber++; - } else if (status === 'nonewline') { - hunkLine = new HunkLine(text.substr(1), status, -1, -1, diffLineNumber++); - } else { - throw new Error(`unknow status type: ${status}`); - } - return hunkLine; - }); - return new Hunk( - hunk.oldStartLine, - hunk.newStartLine, - hunk.oldLineCount, - hunk.newLineCount, - hunk.heading, - hunkLines, - ); - }); - return new FilePatch(patch.oldPath, patch.newPath, patch.status, hunks); - }); + getLoadPromise() { + return this.loadPromise; } - async stageFiles(paths) { - await this.git.stageFiles(paths); + /* + * Use `callback` to request user input from all git strategies. + */ + setPromptCallback(callback) { + this.git.getImplementers().forEach(strategy => strategy.setPromptCallback(callback)); } - async unstageFiles(paths) { - await this.git.unstageFiles(paths); - } + // Event subscription //////////////////////////////////////////////////////////////////////////////////////////////// - async stageFilesFromParentCommit(paths) { - await this.git.unstageFiles(paths, 'HEAD~'); + onDidDestroy(callback) { + return this.emitter.on('did-destroy', callback); } - async applyPatchToIndex(filePatch) { - const patchStr = filePatch.getHeaderString() + filePatch.toString(); - await this.git.applyPatch(patchStr, {index: true}); + onDidChangeState(callback) { + return this.emitter.on('did-change-state', callback); } - async applyPatchToWorkdir(filePatch) { - const patchStr = filePatch.getHeaderString() + filePatch.toString(); - await this.git.applyPatch(patchStr); + onDidUpdate(callback) { + return this.emitter.on('did-update', callback); } - async checkoutPathsAtRevision(paths, revision = 'HEAD') { - await this.git.checkoutFiles(paths, revision); - } + // State-independent actions ///////////////////////////////////////////////////////////////////////////////////////// + // Actions that use direct filesystem access or otherwise don't need `this.git` to be available. async pathHasMergeMarkers(relativePath) { try { @@ -364,200 +129,124 @@ export default class Repository { } } - async abortMerge() { - await this.git.abortMerge(); - return this.refresh(); - } + // State-independent accessors /////////////////////////////////////////////////////////////////////////////////////// - checkoutSide(side, paths) { - return this.git.checkoutSide(side, paths); + getWorkingDirectoryPath() { + return this.workingDirectoryPath; } - isMerging() { - return this.git.isMerging(); + getGitDirectoryPath() { + return path.join(this.getWorkingDirectoryPath(), '.git'); } - isRebasing() { - return this.git.isRebasing(); - } + // Compound Getters ////////////////////////////////////////////////////////////////////////////////////////////////// + // Accessor methods for data derived from other, state-provided getters. - async commit(message, options) { - await this.git.commit(this.formatCommitMessage(message), options); - this.stagedChangesPromise = null; - this.stagedChangesSinceParentCommitPromise = null; + async getUnstagedChanges() { + const {unstagedFiles} = await this.getStatusesForChangedFiles(); + return Object.keys(unstagedFiles).map(filePath => { return {filePath, status: unstagedFiles[filePath]}; }); } - getLastCommit() { - return this.git.getHeadCommit(); + async getStagedChanges() { + const {stagedFiles} = await this.getStatusesForChangedFiles(); + return Object.keys(stagedFiles).map(filePath => { return {filePath, status: stagedFiles[filePath]}; }); } - formatCommitMessage(message) { - // strip out comments - const messageWithoutComments = message.replace(/^#.*$/mg, '').trim(); - - // hard wrap message (except for first line) at 72 characters - let results = []; - messageWithoutComments.split('\n').forEach((line, index) => { - if (line.length <= 72 || index === 0) { - results.push(line); - } else { - const matches = line.match(/.{1,72}(\s|$)|\S+?(\s|$)/g) - .map(match => { - return match.endsWith('\n') ? match.substr(0, match.length - 1) : match; - }); - results = results.concat(matches); - } + async getMergeConflicts() { + const {mergeConflictFiles} = await this.getStatusesForChangedFiles(); + return Object.keys(mergeConflictFiles).map(filePath => { + return {filePath, status: mergeConflictFiles[filePath]}; }); - - return results.join('\n'); } - readFileFromIndex(filePath) { - return this.git.readFileFromIndex(filePath); - } - - push(branchName, options) { - return this.git.push(branchName, options); - } - - fetch(branchName) { - return this.git.fetch(branchName); + async saveDiscardHistory() { + const historySha = await this.createDiscardHistoryBlob(); + await this.setConfig('atomGithub.historySha', historySha); } +} - pull(branchName) { - return this.git.pull(branchName); - } +// The methods named here will be delegated to the current State. +// This list should match the methods decorated with @shouldDelegate in `lib/models/repository-states/state.js`. A test +// case in `test/models/repository.test.js` ensures that these sets match. +const delegates = [ + 'isLoading', + 'isEmpty', + 'isPresent', + 'isDestroyed', - getAheadCount(branchName) { - return this.git.getAheadCount(branchName); - } + 'init', + 'clone', + 'destroy', + 'refresh', - getBehindCount(branchName) { - return this.git.getBehindCount(branchName); - } + 'stageFiles', + 'unstageFiles', + 'stageFilesFromParentCommit', + 'applyPatchToIndex', + 'applyPatchToWorkdir', - getRemoteForBranch(branchName) { - return this.git.getRemoteForBranch(branchName); - } + 'commit', - getCurrentBranch() { - return this.git.getCurrentBranch(); - } + 'merge', + 'abortMerge', + 'checkoutSide', + 'mergeFile', + 'writeMergeConflictToIndex', - getBranches() { - return this.git.getBranches(); - } + 'checkout', + 'checkoutPathsAtRevision', - getRemotes() { - return this.git.getRemotes(); - } + 'fetch', + 'pull', + 'push', - getConfig(configOption, options) { - return this.git.getConfig(configOption, options); - } + 'setConfig', - @autobind - setConfig(configOption, configValue, options) { - return this.git.setConfig(configOption, configValue, options); - } + 'createBlob', + 'expandBlobToFile', - async checkout(branchName, options) { - await this.git.checkout(branchName, options); - } + 'createDiscardHistoryBlob', + 'saveDiscardHistory', + 'updateDiscardHistory', + 'storeBeforeAndAfterBlobs', + 'restoreLastDiscardInTempFiles', + 'popDiscardHistory', + 'clearDiscardHistory', + 'discardWorkDirChangesForPaths', - setCommitState(state) { - this.commitState = state; - } + 'getStatusesForChangedFiles', + 'getStagedChangesSinceParentCommit', + 'isPartiallyStaged', + 'getFilePatchForPath', + 'readFileFromIndex', - getCommitState() { - return this.commitState; - } + 'getLastCommit', - @autobind - createBlob(options = {}) { - return this.git.createBlob(options); - } + 'getBranches', + 'getCurrentBranch', - @autobind - expandBlobToFile(absFilePath, sha) { - return this.git.expandBlobToFile(absFilePath, sha); - } + 'isMerging', + 'isRebasing', - @autobind - getBlobContents(sha) { - return this.git.getBlobContents(sha); - } + 'getRemotes', + 'getRemoteForBranch', - @autobind - mergeFile(oursPath, commonBasePath, theirsPath, resultPath) { - return this.git.mergeFile(oursPath, commonBasePath, theirsPath, resultPath); - } + 'getAheadCount', + 'getBehindCount', - writeMergeConflictToIndex(filePath, commonBaseSha, oursSha, theirsSha) { - return this.git.writeMergeConflictToIndex(filePath, commonBaseSha, oursSha, theirsSha); - } + 'getConfig', - async createDiscardHistoryBlob() { - return await this.discardHistory.createHistoryBlob(); - } + 'getBlobContents', - async saveDiscardHistory() { - const historySha = await this.createDiscardHistoryBlob(); - await this.setConfig('atomGithub.historySha', historySha); - } + 'hasDiscardHistory', + 'getDiscardHistory', + 'getLastHistorySnapshots', +]; - async updateDiscardHistory() { - const history = await loadHistoryPayload(this.git); - this.discardHistory.updateHistory(history); - } +for (let i = 0; i < delegates.length; i++) { + const delegate = delegates[i]; - async storeBeforeAndAfterBlobs(filePaths, isSafe, destructiveAction, partialDiscardFilePath = null) { - const snapshots = await this.discardHistory.storeBeforeAndAfterBlobs( - filePaths, - isSafe, - destructiveAction, - partialDiscardFilePath, - ); - if (snapshots) { await this.saveDiscardHistory(); } - return snapshots; - } - - async restoreLastDiscardInTempFiles(isSafe, partialDiscardFilePath = null) { - return await this.discardHistory.restoreLastDiscardInTempFiles(isSafe, partialDiscardFilePath); - } - - async popDiscardHistory(partialDiscardFilePath = null) { - const removed = await this.discardHistory.popHistory(partialDiscardFilePath); - if (removed) { await this.saveDiscardHistory(); } - } - - async clearDiscardHistory(partialDiscardFilePath = null) { - this.discardHistory.clearHistory(partialDiscardFilePath); - await this.saveDiscardHistory(); - } - - hasDiscardHistory(partialDiscardFilePath = null) { - return this.discardHistory.hasHistory(partialDiscardFilePath); - } - - @autobind - getDiscardHistory(partialDiscardFilePath = null) { - return this.discardHistory.getHistory(partialDiscardFilePath); - } - - @autobind - getLastHistorySnapshots(partialDiscardFilePath = null) { - return this.discardHistory.getLastSnapshots(partialDiscardFilePath); - } - - @autobind - async discardWorkDirChangesForPaths(paths) { - const untrackedFiles = await this.git.getUntrackedFiles(); - const [filesToRemove, filesToCheckout] = partition(paths, f => untrackedFiles.includes(f)); - await this.git.checkoutFiles(filesToCheckout); - await Promise.all(filesToRemove.map(filePath => { - const absPath = path.join(this.getWorkingDirectoryPath(), filePath); - return deleteFileOrFolder(absPath); - })); - } + Repository.prototype[delegate] = function(...args) { + return this.state[delegate](...args); + }; } From 8e44534638f0eed13e47b312ed0d61fba744fc37 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 13 Apr 2017 09:30:54 -0400 Subject: [PATCH 061/161] Repository state machine :wrench: --- lib/models/repository-states/cloning.js | 21 + lib/models/repository-states/destroyed.js | 16 + lib/models/repository-states/empty.js | 21 + lib/models/repository-states/index.js | 10 + lib/models/repository-states/initializing.js | 13 + lib/models/repository-states/loading.js | 32 ++ lib/models/repository-states/present.js | 426 ++++++++++++++++++ lib/models/repository-states/state.js | 430 +++++++++++++++++++ 8 files changed, 969 insertions(+) create mode 100644 lib/models/repository-states/cloning.js create mode 100644 lib/models/repository-states/destroyed.js create mode 100644 lib/models/repository-states/empty.js create mode 100644 lib/models/repository-states/index.js create mode 100644 lib/models/repository-states/initializing.js create mode 100644 lib/models/repository-states/loading.js create mode 100644 lib/models/repository-states/present.js create mode 100644 lib/models/repository-states/state.js diff --git a/lib/models/repository-states/cloning.js b/lib/models/repository-states/cloning.js new file mode 100644 index 0000000000..c92d7af0f1 --- /dev/null +++ b/lib/models/repository-states/cloning.js @@ -0,0 +1,21 @@ +import {mkdirs} from '../../helpers'; + +import State from './state'; +import Loading from './loading'; + +export default class Cloning extends Loading { + constructor(repository, remoteUrl) { + super(repository); + this.remoteUrl = remoteUrl; + } + + async start() { + await mkdirs(this.workdir()); + await this.git().clone(this.remoteUrl, {recursive: true}); + + // See https://github.com/babel/babel/issues/3930 + await Loading.prototype.start.call(this); + } +} + +State.register(Cloning); diff --git a/lib/models/repository-states/destroyed.js b/lib/models/repository-states/destroyed.js new file mode 100644 index 0000000000..cf48c7b252 --- /dev/null +++ b/lib/models/repository-states/destroyed.js @@ -0,0 +1,16 @@ +import State from './state'; + +export default class Destroyed extends State { + start() { + this.didDestroy(); + this.repository.emitter.dispose(); + } + + isDestroyed() { + return true; + } + + destroy() { + // No-op to destroy twice + } +} diff --git a/lib/models/repository-states/empty.js b/lib/models/repository-states/empty.js new file mode 100644 index 0000000000..c3f4039dd6 --- /dev/null +++ b/lib/models/repository-states/empty.js @@ -0,0 +1,21 @@ +import State from './state'; + +export default class Empty extends State { + isEmpty() { + return true; + } + + init() { + return this.transitionTo('Initializing'); + } + + clone(remoteUrl) { + return this.transitionTo('Cloning', remoteUrl); + } + + destroy() { + this.transitionTo('Destroyed'); + } +} + +State.register(Empty); diff --git a/lib/models/repository-states/index.js b/lib/models/repository-states/index.js new file mode 100644 index 0000000000..e57e503eac --- /dev/null +++ b/lib/models/repository-states/index.js @@ -0,0 +1,10 @@ +export {expectedDelegates} from './state'; + +export {default as InitialState} from './loading'; + +// Load and register remaining states +import './empty'; +import './initializing'; +import './cloning'; +import './present'; +import './destroyed'; diff --git a/lib/models/repository-states/initializing.js b/lib/models/repository-states/initializing.js new file mode 100644 index 0000000000..f5eb107167 --- /dev/null +++ b/lib/models/repository-states/initializing.js @@ -0,0 +1,13 @@ +import State from './state'; +import Loading from './loading'; + +export default class Initializing extends Loading { + async start() { + await this.git().init(this.workdir()); + + // See https://github.com/babel/babel/issues/3930 + await Loading.prototype.start.call(this); + } +} + +State.register(Initializing); diff --git a/lib/models/repository-states/loading.js b/lib/models/repository-states/loading.js new file mode 100644 index 0000000000..4214c6e96d --- /dev/null +++ b/lib/models/repository-states/loading.js @@ -0,0 +1,32 @@ +import State from './state'; + +export default class Loading extends State { + async start() { + if (await this.git().isGitRepository()) { + const history = await this.loadHistoryPayload(); + this.transitionTo('Present', history); + } else { + this.transitionTo('Empty'); + } + } + + isLoading() { + return true; + } + + async init() { + await this.getLoadPromise(); + await this.repository.init(); + } + + async clone(remoteUrl) { + await this.getLoadPromise(); + await this.repository.clone(remoteUrl); + } + + destroy() { + this.transitionTo('Destroyed'); + } +} + +State.register(Loading); diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js new file mode 100644 index 0000000000..4385a6d111 --- /dev/null +++ b/lib/models/repository-states/present.js @@ -0,0 +1,426 @@ +import path from 'path'; + +import State from './state'; + +import {deleteFileOrFolder} from '../../helpers'; +import FilePatch from '../file-patch'; +import Hunk from '../hunk'; +import HunkLine from '../hunk-line'; +import DiscardHistory from '../discard-history'; + +class Cache { + constructor() { + this.storage = new Map(); + } + + getOrSet(key, operation) { + const existing = this.storage.get(key); + if (existing !== undefined) { + return existing; + } + + const created = operation(); + this.storage.set(key, created); + return created; + } + + clear() { + this.storage.clear(); + } +} + +export default class Present extends State { + constructor(repository, history) { + super(repository); + + this.cache = new Cache(); + + this.discardHistory = new DiscardHistory( + this.createBlob.bind(this), + this.expandBlobToFile.bind(this), + this.mergeFile.bind(this), + this.workdir.bind(this), + {maxHistoryLength: 60}, + ); + } + + isPresent() { + return true; + } + + destroy() { + this.transitionTo('Destroyed'); + } + + refresh() { + this.cache.clear(); + this.didUpdate(); + } + + // Git operations //////////////////////////////////////////////////////////////////////////////////////////////////// + + // Staging and unstaging + + stageFiles(paths) { + return this.git().stageFiles(paths); + } + + unstageFiles(paths) { + return this.git().unstageFiles(paths); + } + + stageFilesFromParentCommit(paths) { + return this.git().unstageFiles(paths, 'HEAD~'); + } + + applyPatchToIndex(filePatch) { + const patchStr = filePatch.getHeaderString() + filePatch.toString(); + return this.git().applyPatch(patchStr, {index: true}); + } + + applyPatchToWorkdir(filePatch) { + const patchStr = filePatch.getHeaderString() + filePatch.toString(); + return this.git().applyPatch(patchStr); + } + + // Committing + + commit(message, options) { + return this.git().commit(formatCommitMessage(message), options); + } + + // Merging + + merge(branchName) { + return this.git().merge(branchName); + } + + async abortMerge() { + await this.git().abortMerge(); + this.refresh(); + } + + checkoutSide(side, paths) { + return this.git().checkoutSide(side, paths); + } + + mergeFile(oursPath, commonBasePath, theirsPath, resultPath) { + return this.git().mergeFile(oursPath, commonBasePath, theirsPath, resultPath); + } + + writeMergeConflictToIndex(filePath, commonBaseSha, oursSha, theirsSha) { + return this.git().writeMergeConflictToIndex(filePath, commonBaseSha, oursSha, theirsSha); + } + + // Checkout + + checkout(revision, options = {}) { + return this.git().checkout(revision, options); + } + + checkoutPathsAtRevision(paths, revision = 'HEAD') { + return this.git().checkoutFiles(paths, revision); + } + + // Remote interactions + + fetch(branchName) { + return this.git().fetch(branchName); + } + + pull(branchName) { + return this.git().pull(branchName); + } + + push(branchName, options) { + return this.git().push(branchName, options); + } + + // Configuration + + setConfig(option, value, options) { + return this.git().setConfig(option, value, options); + } + + // Direct blob interactions + + createBlob(options) { + return this.git().createBlob(options); + } + + expandBlobToFile(absFilePath, sha) { + return this.git().expandBlobToFile(absFilePath, sha); + } + + // Discard history + + createDiscardHistoryBlob() { + return this.discardHistory.createHistoryBlob(); + } + + async updateDiscardHistory() { + const history = await this.loadHistoryPayload(); + this.discardHistory.updateHistory(history); + } + + async storeBeforeAndAfterBlobs(filePaths, isSafe, destructiveAction, partialDiscardFilePath = null) { + const snapshots = await this.discardHistory.storeBeforeAndAfterBlobs( + filePaths, + isSafe, + destructiveAction, + partialDiscardFilePath, + ); + if (snapshots) { await this.saveDiscardHistory(); } + return snapshots; + } + + restoreLastDiscardInTempFiles(isSafe, partialDiscardFilePath = null) { + return this.discardHistory.restoreLastDiscardInTempFiles(isSafe, partialDiscardFilePath); + } + + async popDiscardHistory(partialDiscardFilePath = null) { + const removed = await this.discardHistory.popHistory(partialDiscardFilePath); + if (removed) { await this.saveDiscardHistory(); } + } + + clearDiscardHistory(partialDiscardFilePath = null) { + this.discardHistory.clearHistory(partialDiscardFilePath); + return this.saveDiscardHistory(); + } + + async discardWorkDirChangesForPaths(paths) { + const untrackedFiles = await this.git.getUntrackedFiles(); + const [filesToRemove, filesToCheckout] = partition(paths, f => untrackedFiles.includes(f)); + await this.git.checkoutFiles(filesToCheckout); + await Promise.all(filesToRemove.map(filePath => { + const absPath = path.join(this.getWorkingDirectoryPath(), filePath); + return deleteFileOrFolder(absPath); + })); + } + + // Accessors ///////////////////////////////////////////////////////////////////////////////////////////////////////// + + // Index queries + + getStatusesForChangedFiles() { + return this.cache.getOrSet('changed-files', () => { + return this.git().getStatusesForChangedFiles(); + }); + } + + getStagedChangesSinceParentCommit() { + return this.cache.getOrSet('staged-changes-since-parent-commit', async () => { + try { + const stagedFiles = await this.git().diffFileStatus({staged: true, target: 'HEAD~'}); + return Object.keys(stagedFiles).map(filePath => ({filePath, status: stagedFiles[filePath]})); + } catch (e) { + if (e.message.includes('ambiguous argument \'HEAD~\'')) { + return []; + } else { + throw e; + } + } + }); + } + + isPartiallyStaged(fileName) { + return this.cache.getOrSet(`is-partially-staged:${fileName}`, () => { + return this.git().isPartiallyStaged(fileName); + }); + } + + getFilePatchForPath(filePath, {staged, amending} = {staged: false, amending: false}) { + let desc = ''; + if (staged && amending) { + desc = 'p'; + } else if (staged) { + desc = 's'; + } else { + desc = 'u'; + } + + return this.cache.getOrSet(`file-patch:${desc}:${filePath}`, async () => { + const options = {staged, amending}; + if (amending) { + options.baseCommit = 'HEAD~'; + } + + const rawDiff = await this.git().getDiffForFilePath(filePath, options); + if (rawDiff) { + const [filePatch] = buildFilePatchesFromRawDiffs([rawDiff]); + return filePatch; + } else { + return null; + } + }); + } + + readFileFromIndex(filePath) { + return this.cache.getOrSet(`index:${filePath}`, () => { + return this.git().readFileFromIndex(filePath); + }); + } + + // Commit access + + getLastCommit() { + return this.cache.getOrSet('last-commit', () => { + return this.git().getHeadCommit(); + }); + } + + // Branches + + getBranches() { + return this.cache.getOrSet('branches', () => { + return this.git().getBranches(); + }); + } + + getCurrentBranch() { + return this.cache.getOrSet('current-branch', () => { + return this.git().getCurrentBranch(); + }); + } + + // Merging and rebasing status + + isMerging() { + return this.git().isMerging(); + } + + isRebasing() { + return this.git().isRebasing(); + } + + // Remotes + + getRemotes() { + return this.cache.getOrSet('remotes', () => { + return this.git().getRemotes(); + }); + } + + getRemoteForBranch(branchName) { + return this.cache.getOrSet(`remote-for-branch:${branchName}`, () => { + return this.git().getRemoteForBranch(branchName); + }); + } + + getAheadCount(branchName) { + return this.cache.getOrSet(`ahead-count:${branchName}`, () => { + return this.git().getAheadCount(branchName); + }); + } + + getBehindCount(branchName) { + return this.cache.getOrSet(`behind-count:${branchName}`, () => { + return this.git().getBehindCount(branchName); + }); + } + + getConfig(option, {local} = {local: false}) { + const desc = local ? 'l' : ''; + return this.cache.getOrSet(`config:${desc}:${option}`, () => { + return this.git().getConfig(option, {local}); + }); + } + + // Direct blob access + + getBlobContents(sha) { + return this.cache.getOrSet(`blob:${sha}`, () => { + return this.git().getBlobContents(sha); + }); + } + + // Discard history + + hasDiscardHistory(partialDiscardFilePath = null) { + return this.discardHistory.hasHistory(partialDiscardFilePath); + } + + getDiscardHistory(partialDiscardFilePath = null) { + return this.discardHistory.getHistory(partialDiscardFilePath); + } + + getLastHistorySnapshots(partialDiscardFilePath = null) { + return this.discardHistory.getLastSnapshots(partialDiscardFilePath); + } +} + +State.register(Present); + +function partition(array, predicate) { + const matches = []; + const nonmatches = []; + array.forEach(item => { + if (predicate(item)) { + matches.push(item); + } else { + nonmatches.push(item); + } + }); + return [matches, nonmatches]; +} + +function formatCommitMessage(message) { + // strip out comments + const messageWithoutComments = message.replace(/^#.*$/mg, '').trim(); + + // hard wrap message (except for first line) at 72 characters + let results = []; + messageWithoutComments.split('\n').forEach((line, index) => { + if (line.length <= 72 || index === 0) { + results.push(line); + } else { + const matches = line.match(/.{1,72}(\s|$)|\S+?(\s|$)/g) + .map(match => { + return match.endsWith('\n') ? match.substr(0, match.length - 1) : match; + }); + results = results.concat(matches); + } + }); + + return results.join('\n'); +} + +function buildFilePatchesFromRawDiffs(rawDiffs) { + let diffLineNumber = 0; + return rawDiffs.map(patch => { + const hunks = patch.hunks.map(hunk => { + let oldLineNumber = hunk.oldStartLine; + let newLineNumber = hunk.newStartLine; + const hunkLines = hunk.lines.map(line => { + const status = HunkLine.statusMap[line[0]]; + const text = line.slice(1); + let hunkLine; + if (status === 'unchanged') { + hunkLine = new HunkLine(text, status, oldLineNumber, newLineNumber, diffLineNumber++); + oldLineNumber++; + newLineNumber++; + } else if (status === 'added') { + hunkLine = new HunkLine(text, status, -1, newLineNumber, diffLineNumber++); + newLineNumber++; + } else if (status === 'deleted') { + hunkLine = new HunkLine(text, status, oldLineNumber, -1, diffLineNumber++); + oldLineNumber++; + } else if (status === 'nonewline') { + hunkLine = new HunkLine(text.substr(1), status, -1, -1, diffLineNumber++); + } else { + throw new Error(`unknow status type: ${status}`); + } + return hunkLine; + }); + return new Hunk( + hunk.oldStartLine, + hunk.newStartLine, + hunk.oldLineCount, + hunk.newLineCount, + hunk.heading, + hunkLines, + ); + }); + return new FilePatch(patch.oldPath, patch.newPath, patch.status, hunks); + }); +} diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js new file mode 100644 index 0000000000..026da1b8b7 --- /dev/null +++ b/lib/models/repository-states/state.js @@ -0,0 +1,430 @@ +import {nullCommit} from '../commit'; +import {nullBranch} from '../branch'; +import {nullRemote} from '../remote'; + +export const expectedDelegates = []; + +/** + * Map of registered subclasses to allow states to transition to one another without circular dependencies. + * Subclasses of State should call `State.register` to add themselves here. + */ +const stateConstructors = new Map(); + +/** + * Methods marked with this decorator on State should be delegated from a Repository to its current state. This will + * be verified by a unit test in `repository.test.js`. + */ +function shouldDelegate(target, name, descriptor) { + expectedDelegates.push(name); + return descriptor; +} + +/** + * Base class for Repository states. Implements default "null" behavior. + */ +export default class State { + constructor(repository) { + this.repository = repository; + } + + static register(Subclass) { + stateConstructors.set(Subclass.name, Subclass); + } + + // This state has just been entered. Perform any asynchronous initialization that needs to occur. + start() { + return Promise.resolve(); + } + + // State probe predicates //////////////////////////////////////////////////////////////////////////////////////////// + // Allow external callers to identify which state a Repository is in if necessary. + + @shouldDelegate + isLoading() { + return false; + } + + @shouldDelegate + isEmpty() { + return false; + } + + @shouldDelegate + isPresent() { + return false; + } + + @shouldDelegate + isDestroyed() { + return false; + } + + // Lifecycle actions ///////////////////////////////////////////////////////////////////////////////////////////////// + // These generally default to rejecting a Promise with an error. + + @shouldDelegate + init() { + return unsupportedOperationPromise(this, 'init'); + } + + @shouldDelegate + clone(remoteUrl) { + return unsupportedOperationPromise(this, 'clone'); + } + + @shouldDelegate + destroy() { + return unsupportedOperation(this, 'destroy'); + } + + @shouldDelegate + refresh() { + // No-op + } + + // Git operations //////////////////////////////////////////////////////////////////////////////////////////////////// + // These default to rejecting a Promise with an error stating that the operation is not supported in the current + // state. + + // Staging and unstaging + + @shouldDelegate + stageFiles(paths) { + return unsupportedOperationPromise(this, 'stageFiles'); + } + + @shouldDelegate + unstageFiles(paths) { + return unsupportedOperationPromise(this, 'unstageFiles'); + } + + @shouldDelegate + stageFilesFromParentCommit(paths) { + return unsupportedOperationPromise(this, 'stageFilesFromParentCommit'); + } + + @shouldDelegate + applyPatchToIndex(patch) { + return unsupportedOperationPromise(this, 'applyPatchToIndex'); + } + + @shouldDelegate + applyPatchToWorkdir(patch) { + return unsupportedOperationPromise(this, 'applyPatchToWorkdir'); + } + + // Committing + + @shouldDelegate + commit(message, options) { + return unsupportedOperationPromise(this, 'commit'); + } + + // Merging + + @shouldDelegate + merge(branchName) { + return unsupportedOperationPromise(this, 'merge'); + } + + @shouldDelegate + abortMerge() { + return unsupportedOperationPromise(this, 'abortMerge'); + } + + @shouldDelegate + checkoutSide(side, paths) { + return unsupportedOperationPromise(this, 'checkoutSide'); + } + + @shouldDelegate + mergeFile(oursPath, commonBasePath, theirsPath, resultPath) { + return unsupportedOperationPromise(this, 'mergeFile'); + } + + @shouldDelegate + writeMergeConflictToIndex(filePath, commonBaseSha, oursSha, theirsSha) { + return unsupportedOperationPromise(this, 'writeMergeConflictToIndex'); + } + + // Checkout + + @shouldDelegate + checkout(revision, options = {}) { + return unsupportedOperationPromise(this, 'checkout'); + } + + @shouldDelegate + checkoutPathsAtRevision(paths, revision = 'HEAD') { + return unsupportedOperationPromise(this, 'checkoutPathsAtRevision'); + } + + // Remote interactions + + @shouldDelegate + fetch(branchName) { + return unsupportedOperationPromise(this, 'fetch'); + } + + @shouldDelegate + pull(branchName) { + return unsupportedOperationPromise(this, 'pull'); + } + + @shouldDelegate + push(branchName) { + return unsupportedOperationPromise(this, 'push'); + } + + // Configuration + + @shouldDelegate + setConfig(option, value, {replaceAll} = {}) { + return unsupportedOperationPromise(this, 'setConfig'); + } + + // Direct blob interactions + + @shouldDelegate + createBlob({filePath, stdin} = {}) { + return unsupportedOperationPromise(this, 'createBlob'); + } + + @shouldDelegate + expandBlobToFile(absFilePath, sha) { + return unsupportedOperationPromise(this, 'expandBlobToFile'); + } + + // Discard history + + @shouldDelegate + createDiscardHistoryBlob() { + return unsupportedOperationPromise(this, 'createDiscardHistoryBlob'); + } + + @shouldDelegate + saveDiscardHistory() { + return unsupportedOperationPromise(this, 'saveDiscardHistory'); + } + + @shouldDelegate + updateDiscardHistory() { + return unsupportedOperationPromise(this, 'updateDiscardHistory'); + } + + @shouldDelegate + storeBeforeAndAfterBlobs(filePaths, isSafe, destructiveAction, partialDiscardFilePath = null) { + return unsupportedOperationPromise(this, 'storeBeforeAndAfterBlobs'); + } + + @shouldDelegate + restoreLastDiscardInTempFiles(isSafe, partialDiscardFilePath = null) { + return unsupportedOperationPromise(this, 'restoreLastDiscardInTempFiles'); + } + + @shouldDelegate + popDiscardHistory(partialDiscardFilePath = null) { + return unsupportedOperationPromise(this, 'popDiscardHistory'); + } + + @shouldDelegate + clearDiscardHistory(partialDiscardFilePath = null) { + return unsupportedOperationPromise(this, 'clearDiscardHistory'); + } + + @shouldDelegate + discardWorkDirChangesForPaths(paths) { + return unsupportedOperationPromise(this, 'discardWorkDirChangesForPaths'); + } + + // Accessors ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // When possible, these default to "empty" results when invoked in states that don't have information available, or + // fail in a way that's consistent with the requested information not being found. + + // Index queries + + @shouldDelegate + getStatusesForChangedFiles() { + return Promise.resolve({ + stagedFiles: [], + unstagedFiles: [], + mergeConflictFiles: [], + }); + } + + @shouldDelegate + getStagedChangesSinceParentCommit() { + return Promise.resolve([]); + } + + @shouldDelegate + isPartiallyStaged(fileName) { + return Promise.resolve(false); + } + + @shouldDelegate + getFilePatchForPath(filePath, options = {}) { + return Promise.resolve(null); + } + + @shouldDelegate + readFileFromIndex(filePath) { + return Promise.reject(new Error(`fatal: Path ${filePath} does not exist (neither on disk nor in the index).`)); + } + + // Commit access + + @shouldDelegate + getLastCommit() { + return Promise.resolve(nullCommit); + } + + // Branches + + @shouldDelegate + getBranches() { + return Promise.resolve([]); + } + + @shouldDelegate + getCurrentBranch() { + return Promise.resolve(nullBranch); + } + + // Merging and rebasing status + + @shouldDelegate + isMerging() { + return Promise.resolve(false); + } + + @shouldDelegate + isRebasing() { + return Promise.resolve(false); + } + + // Remotes + + @shouldDelegate + getRemotes() { + return Promise.resolve([]); + } + + @shouldDelegate + getRemoteForBranch(branchName) { + return Promise.resolve(nullRemote); + } + + @shouldDelegate + getAheadCount(branchName) { + return Promise.resolve(null); + } + + @shouldDelegate + getBehindCount(branchName) { + return Promise.resolve(null); + } + + @shouldDelegate + getConfig(option, {local} = {}) { + return Promise.resolve(null); + } + + // Direct blob access + + @shouldDelegate + getBlobContents(sha) { + return Promise.reject(new Error(`fatal: Not a valid object name ${sha}`)); + } + + // Discard history + + @shouldDelegate + hasDiscardHistory(partialDiscardFilePath = null) { + return false; + } + + @shouldDelegate + getDiscardHistory(partialDiscardFilePath = null) { + return []; + } + + @shouldDelegate + getLastHistorySnapshots(partialDiscardFilePath = null) { + return null; + } + + // Internal ////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Non-delegated methods that provide subclasses with convenient access to Repository properties. + + git() { + return this.repository.git; + } + + workdir() { + return this.repository.getWorkingDirectoryPath(); + } + + // Return a Promise that will resolve once the state transitions from Loading. + getLoadPromise() { + return this.repository.getLoadPromise(); + } + + // Initiate a transition to another state. + transitionTo(stateName, ...payload) { + const StateConstructor = stateConstructors.get(stateName); + if (StateConstructor === undefined) { + throw new Error(`Attempt to transition to unrecognized state ${stateName}`); + } + return this.repository.transition(this, StateConstructor, ...payload); + } + + // Event broadcast + + didDestroy() { + return this.repository.emitter.emit('did-destroy'); + } + + didUpdate() { + return this.repository.emitter.emit('did-update'); + } + + // Parse a DiscardHistory payload from the SHA recorded in config. + async loadHistoryPayload() { + const historySha = await this.git().getConfig('atomGithub.historySha'); + if (!historySha) { + return {}; + } + + let blob; + try { + blob = await this.getBlobContents(historySha); + } catch (e) { + if (/Not a valid object name/.test(e.stdErr)) { + return {}; + } + + throw e; + } + + try { + return JSON.parse(blob); + } catch (e) { + return {}; + } + } + + // Debugging assistance. + + toString() { + return this.constructor.name; + } +} + +function unsupportedOperation(self, opName) { + throw new Error(`${opName} is not available in ${self} state`); +} + +function unsupportedOperationPromise(self, opName) { + return Promise.reject(new Error(`${opName} is not available in ${self} state`)); +} From 589192c19a16703408420af7699f318cff25acb9 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 13 Apr 2017 12:37:58 -0400 Subject: [PATCH 062/161] fetch, push, and pull accept a remote name argument --- lib/git-shell-out-strategy.js | 9 +++------ test/git-strategies.test.js | 10 +++++----- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index 213ba09e71..699e1aeea3 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -472,18 +472,15 @@ export default class GitShellOutStrategy { } } - async fetch(branchName) { - const remote = await this.getRemoteForBranch(branchName); + fetch(remote, branchName) { return this.exec(['fetch', remote, branchName], {useGitPromptServer: true, writeOperation: true}); } - async pull(branchName) { - const remote = await this.getRemoteForBranch(branchName); + pull(remote, branchName) { return this.gpgExec(['pull', remote, branchName], {useGitPromptServer: true, writeOperation: true}); } - async push(branchName, options = {}) { - const remote = await this.getRemoteForBranch(branchName); + push(remote, branchName, options = {}) { const args = ['push', remote || 'origin', branchName]; if (options.setUpstream) { args.push('--set-upstream'); } if (options.force) { args.push('--force'); } diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index a577956183..f876811199 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -468,7 +468,7 @@ import {fsStat} from '../lib/helpers'; await git.exec(['config', 'push.default', 'upstream']); assert.equal(await git.getBehindCount('master'), 0); assert.equal(await git.getAheadCount('master'), 0); - await git.fetch('master'); + await git.fetch('origin', 'master'); assert.equal(await git.getBehindCount('master'), 1); assert.equal(await git.getAheadCount('master'), 0); await git.commit('new commit', {allowEmpty: true}); @@ -646,7 +646,7 @@ import {fsStat} from '../lib/helpers'; command: 'pull', progressiveTense: 'pulling', usesPromptServerAlready: true, - action: () => git.pull('some-branch'), + action: () => git.pull('origin', 'some-branch'), }, ]; @@ -719,17 +719,17 @@ import {fsStat} from '../lib/helpers'; { command: 'fetch', progressiveTense: 'fetching', - action: () => git.fetch('some-branch'), + action: () => git.fetch('origin', 'some-branch'), }, { command: 'pull', progressiveTense: 'pulling', - action: () => git.pull('some-branch'), + action: () => git.pull('origin', 'some-branch'), }, { command: 'push', progressiveTense: 'pushing', - action: () => git.push('some-branch'), + action: () => git.push('origin', 'some-branch'), }, ]; From 6b5f83220c059aadac8cf4923caca67088b7a89c Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 13 Apr 2017 12:38:46 -0400 Subject: [PATCH 063/161] Move getRemoteForBranchName() into Repository --- lib/git-shell-out-strategy.js | 9 --------- lib/models/repository.js | 12 ++++++++++-- test/git-strategies.test.js | 15 --------------- 3 files changed, 10 insertions(+), 26 deletions(-) diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index 699e1aeea3..759b5e6005 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -463,15 +463,6 @@ export default class GitShellOutStrategy { return this.exec(args, {writeOperation: true}); } - async getRemoteForBranch(branchName) { - try { - const output = await this.exec(['config', `branch.${branchName}.remote`]); - return output.trim(); - } catch (e) { - return null; - } - } - fetch(remote, branchName) { return this.exec(['fetch', remote, branchName], {useGitPromptServer: true, writeOperation: true}); } diff --git a/lib/models/repository.js b/lib/models/repository.js index 5eb8ff21b5..2e5ec01bf8 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -4,7 +4,7 @@ import {Emitter} from 'event-kit'; import CompositeGitStrategy from '../composite-git-strategy'; import {readFile} from '../helpers'; - +import Remote, {nullRemote} from './remote'; import {InitialState} from './repository-states'; const MERGE_MARKER_REGEX = /^(>|<){7} \S+$/m; @@ -159,6 +159,15 @@ export default class Repository { }); } + async getRemoteForBranch(branchName) { + const name = await this.getConfig(`branch.${branchName}.remote`); + if (name === null) { + return nullRemote; + } else { + return new Remote(name); + } + } + async saveDiscardHistory() { const historySha = await this.createDiscardHistoryBlob(); await this.setConfig('atomGithub.historySha', historySha); @@ -229,7 +238,6 @@ const delegates = [ 'isRebasing', 'getRemotes', - 'getRemoteForBranch', 'getAheadCount', 'getBehindCount', diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index f876811199..3b2022e7c5 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -542,18 +542,6 @@ import {fsStat} from '../lib/helpers'; }); }); - describe('getRemoteForBranch(branchName)', function() { - it('returns the name of the remote associated with the branch, and null if none exists', async function() { - const workingDirPath = await cloneRepository('three-files'); - const git = createTestStrategy(workingDirPath); - assert.equal(await git.getRemoteForBranch('master'), 'origin'); - await git.exec(['remote', 'rename', 'origin', 'foo']); - assert.equal(await git.getRemoteForBranch('master'), 'foo'); - await git.exec(['remote', 'rm', 'foo']); - assert.isNull(await git.getRemoteForBranch('master')); - }); - }); - describe('getBranches()', function() { it('returns an array of all branches', async function() { const workingDirPath = await cloneRepository('three-files'); @@ -626,7 +614,6 @@ import {fsStat} from '../lib/helpers'; beforeEach(async function() { const workingDirPath = await cloneRepository('multiple-commits'); git = createTestStrategy(workingDirPath); - sinon.stub(git, 'getRemoteForBranch').returns(Promise.resolve('origin')); }); const operations = [ @@ -701,8 +688,6 @@ import {fsStat} from '../lib/helpers'; prompt: Promise.resolve(''), }); - sinon.stub(git, 'getRemoteForBranch').returns(Promise.resolve('origin')); - originalEnv = {}; ['PATH', 'DISPLAY', 'GIT_ASKPASS', 'SSH_ASKPASS', 'GIT_SSH_COMMAND'].forEach(varName => { originalEnv[varName] = process.env[varName]; From 56d277e0d313920575a85c1faba59d2d2c17522e Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 13 Apr 2017 12:42:51 -0400 Subject: [PATCH 064/161] Remote.getNameOr() lets you specify a default --- lib/models/remote.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/models/remote.js b/lib/models/remote.js index 05d237faa9..e0c843fc5f 100644 --- a/lib/models/remote.js +++ b/lib/models/remote.js @@ -7,6 +7,10 @@ export default class Remote { return this.name; } + getNameOr(fallback) { + return this.getName(); + } + isPresent() { return true; } @@ -17,6 +21,10 @@ export const nullRemote = { return ''; }, + getNameOr(fallback) { + return fallback; + }, + isPresent() { return false; }, From aff481db32dd5703d09bf9827a6e6ac94fbc0189 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 13 Apr 2017 12:43:08 -0400 Subject: [PATCH 065/161] saveDiscardHistory() is a native Repository method --- lib/models/repository.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/models/repository.js b/lib/models/repository.js index 2e5ec01bf8..c37ec4f7bc 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -215,7 +215,6 @@ const delegates = [ 'expandBlobToFile', 'createDiscardHistoryBlob', - 'saveDiscardHistory', 'updateDiscardHistory', 'storeBeforeAndAfterBlobs', 'restoreLastDiscardInTempFiles', From d1eb89f4252c9274b4cda0957fa4b752a04c5c2a Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 13 Apr 2017 12:44:29 -0400 Subject: [PATCH 066/161] :fire: methods that should no longer be delegated --- lib/models/repository-states/state.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index 026da1b8b7..e7969c3416 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -1,6 +1,5 @@ import {nullCommit} from '../commit'; import {nullBranch} from '../branch'; -import {nullRemote} from '../remote'; export const expectedDelegates = []; @@ -202,11 +201,6 @@ export default class State { return unsupportedOperationPromise(this, 'createDiscardHistoryBlob'); } - @shouldDelegate - saveDiscardHistory() { - return unsupportedOperationPromise(this, 'saveDiscardHistory'); - } - @shouldDelegate updateDiscardHistory() { return unsupportedOperationPromise(this, 'updateDiscardHistory'); @@ -310,11 +304,6 @@ export default class State { return Promise.resolve([]); } - @shouldDelegate - getRemoteForBranch(branchName) { - return Promise.resolve(nullRemote); - } - @shouldDelegate getAheadCount(branchName) { return Promise.resolve(null); @@ -370,6 +359,14 @@ export default class State { return this.repository.getLoadPromise(); } + getRemoteForBranch(branchName) { + return this.repository.getRemoteForBranch(branchName); + } + + saveDiscardHistory() { + return this.repository.saveDiscardHistory(); + } + // Initiate a transition to another state. transitionTo(stateName, ...payload) { const StateConstructor = stateConstructors.get(stateName); From 70bebc347a820942d8da90a7b6b98f1d70d51005 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 13 Apr 2017 12:45:14 -0400 Subject: [PATCH 067/161] Use getBlobContents() from git strategy from loadHistoryPayload() --- lib/models/repository-states/state.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index e7969c3416..e0dd1afc30 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -395,7 +395,7 @@ export default class State { let blob; try { - blob = await this.getBlobContents(historySha); + blob = await this.git().getBlobContents(historySha); } catch (e) { if (/Not a valid object name/.test(e.stdErr)) { return {}; From 1332c64442de6f808e2a362ac7cc0b89345faf42 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 13 Apr 2017 12:45:31 -0400 Subject: [PATCH 068/161] Cache invalidation with a fancy decorator --- lib/models/repository-states/present.js | 94 +++++++++++++++++++------ 1 file changed, 74 insertions(+), 20 deletions(-) diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index 4385a6d111..c22f1cf937 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -24,11 +24,40 @@ class Cache { return created; } + invalidate(...keys) { + for (let i = 0; i < keys.length; i++) { + this.storage.delete(keys[i]); + } + } + clear() { this.storage.clear(); } } +/** + * Decorator for an async method that invalidates the cache after execution (regardless of success or failure.) + */ +function invalidate() { + return function(target, name, descriptor) { + const original = descriptor.value; + descriptor.value = function(...args) { + const cache = this.cache; + + return original.apply(this, args).then( + result => { + cache.clear(); + return result; + }, + err => { + return Promise.reject(err); + }, + ); + }; + return descriptor; + }; +} + export default class Present extends State { constructor(repository, history) { super(repository); @@ -61,23 +90,28 @@ export default class Present extends State { // Staging and unstaging + @invalidate() stageFiles(paths) { return this.git().stageFiles(paths); } + @invalidate() unstageFiles(paths) { return this.git().unstageFiles(paths); } + @invalidate() stageFilesFromParentCommit(paths) { return this.git().unstageFiles(paths, 'HEAD~'); } + @invalidate() applyPatchToIndex(filePatch) { const patchStr = filePatch.getHeaderString() + filePatch.toString(); return this.git().applyPatch(patchStr, {index: true}); } + @invalidate() applyPatchToWorkdir(filePatch) { const patchStr = filePatch.getHeaderString() + filePatch.toString(); return this.git().applyPatch(patchStr); @@ -85,65 +119,86 @@ export default class Present extends State { // Committing + @invalidate() commit(message, options) { return this.git().commit(formatCommitMessage(message), options); } // Merging + @invalidate() merge(branchName) { return this.git().merge(branchName); } - async abortMerge() { - await this.git().abortMerge(); - this.refresh(); + @invalidate() + abortMerge() { + return this.git().abortMerge(); } + @invalidate() checkoutSide(side, paths) { return this.git().checkoutSide(side, paths); } + @invalidate() mergeFile(oursPath, commonBasePath, theirsPath, resultPath) { return this.git().mergeFile(oursPath, commonBasePath, theirsPath, resultPath); } + @invalidate() writeMergeConflictToIndex(filePath, commonBaseSha, oursSha, theirsSha) { return this.git().writeMergeConflictToIndex(filePath, commonBaseSha, oursSha, theirsSha); } // Checkout + @invalidate() checkout(revision, options = {}) { return this.git().checkout(revision, options); } + @invalidate() checkoutPathsAtRevision(paths, revision = 'HEAD') { return this.git().checkoutFiles(paths, revision); } // Remote interactions - fetch(branchName) { - return this.git().fetch(branchName); + @invalidate() + async fetch(branchName) { + const remote = await this.getRemoteForBranch(branchName); + if (!remote.isPresent()) { + return; + } + await this.git().fetch(remote.getName(), branchName); } - pull(branchName) { - return this.git().pull(branchName); + @invalidate() + async pull(branchName) { + const remote = await this.getRemoteForBranch(branchName); + if (!remote.isPresent()) { + return; + } + await this.git().pull(remote.getName(), branchName); } - push(branchName, options) { - return this.git().push(branchName, options); + @invalidate() + async push(branchName, options) { + const remote = await this.getRemoteForBranch(branchName); + return this.git().push(remote.getNameOr('origin'), branchName, options); } // Configuration + @invalidate() setConfig(option, value, options) { return this.git().setConfig(option, value, options); } // Direct blob interactions + @invalidate() createBlob(options) { return this.git().createBlob(options); } @@ -170,7 +225,9 @@ export default class Present extends State { destructiveAction, partialDiscardFilePath, ); - if (snapshots) { await this.saveDiscardHistory(); } + if (snapshots) { + await this.saveDiscardHistory(); + } return snapshots; } @@ -180,7 +237,9 @@ export default class Present extends State { async popDiscardHistory(partialDiscardFilePath = null) { const removed = await this.discardHistory.popHistory(partialDiscardFilePath); - if (removed) { await this.saveDiscardHistory(); } + if (removed) { + await this.saveDiscardHistory(); + } } clearDiscardHistory(partialDiscardFilePath = null) { @@ -188,12 +247,13 @@ export default class Present extends State { return this.saveDiscardHistory(); } + @invalidate() async discardWorkDirChangesForPaths(paths) { - const untrackedFiles = await this.git.getUntrackedFiles(); + const untrackedFiles = await this.git().getUntrackedFiles(); const [filesToRemove, filesToCheckout] = partition(paths, f => untrackedFiles.includes(f)); - await this.git.checkoutFiles(filesToCheckout); + await this.git().checkoutFiles(filesToCheckout); await Promise.all(filesToRemove.map(filePath => { - const absPath = path.join(this.getWorkingDirectoryPath(), filePath); + const absPath = path.join(this.workdir(), filePath); return deleteFileOrFolder(absPath); })); } @@ -301,12 +361,6 @@ export default class Present extends State { }); } - getRemoteForBranch(branchName) { - return this.cache.getOrSet(`remote-for-branch:${branchName}`, () => { - return this.git().getRemoteForBranch(branchName); - }); - } - getAheadCount(branchName) { return this.cache.getOrSet(`ahead-count:${branchName}`, () => { return this.git().getAheadCount(branchName); From 979b815b8e1250cd85cf80681e139fa9ec64b2b3 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 13 Apr 2017 12:45:59 -0400 Subject: [PATCH 069/161] Refresh explicitly in tests --- test/models/repository.test.js | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/test/models/repository.test.js b/test/models/repository.test.js index c48e8bae8c..67bebe7efa 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -499,17 +499,27 @@ describe('Repository', function() { }); describe('getRemoteForBranch(branchName)', function() { - it('returns the remote name associated to the supplied branch name, null if none exists', async function() { + it('returns the remote associated to the supplied branch name', async function() { const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); const localRepo = new Repository(localRepoPath); await localRepo.getLoadPromise(); - assert.equal(await localRepo.getRemoteForBranch('master'), 'origin'); + const remote0 = await localRepo.getRemoteForBranch('master'); + assert.isTrue(remote0.isPresent()); + assert.equal(remote0.getName(), 'origin'); + await localRepo.git.exec(['remote', 'rename', 'origin', 'foo']); - assert.equal(await localRepo.getRemoteForBranch('master'), 'foo'); + localRepo.refresh(); + + const remote1 = await localRepo.getRemoteForBranch('master'); + assert.isTrue(remote1.isPresent()); + assert.equal(remote1.getName(), 'foo'); await localRepo.git.exec(['remote', 'rm', 'foo']); - assert.isNull(await localRepo.getRemoteForBranch('master')); + localRepo.refresh(); + + const remote2 = await localRepo.getRemoteForBranch('master'); + assert.isFalse(remote2.isPresent()); }); }); @@ -719,8 +729,8 @@ describe('Repository', function() { assert.equal(await repo.isMerging(), true); try { - await assert.isRejected(repo.abortMerge(), /^git merge --abort/); - assert(false); + await repo.abortMerge(); + assert.fail(null, null, 'repo.abortMerge() unexepctedly succeeded'); } catch (e) { assert.match(e.command, /^git merge --abort/); } From 0d49c457926aaea39eb116c07876795dbaca70d9 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 11:34:26 -0400 Subject: [PATCH 070/161] Model detached Branches --- lib/models/branch.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/models/branch.js b/lib/models/branch.js index 9047086b5b..3d2aecd3ea 100644 --- a/lib/models/branch.js +++ b/lib/models/branch.js @@ -1,12 +1,23 @@ +const DETACHED = {}; + export default class Branch { - constructor(name) { + constructor(name, detached = null) { this.name = name; + this.isDetached = detached === DETACHED; + } + + static createDetached(describe) { + return new Branch(describe, DETACHED); } getName() { return this.name; } + isDetached() { + return this.isDetached; + } + isPresent() { return true; } @@ -17,6 +28,10 @@ export const nullBranch = { return ''; }, + isDetached() { + return false; + }, + isPresent() { return false; }, From 5e07c29c94c634c4feaf6830f60661490ad8fa7d Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 11:35:35 -0400 Subject: [PATCH 071/161] Move URL extraction to Remote --- lib/models/remote.js | 43 +++++++++++++++++++++++++++++++++- lib/models/repository.js | 19 --------------- test/models/remote.test.js | 41 ++++++++++++++++++++++++++++++++ test/models/repository.test.js | 32 ------------------------- 4 files changed, 83 insertions(+), 52 deletions(-) create mode 100644 test/models/remote.test.js diff --git a/lib/models/remote.js b/lib/models/remote.js index e0c843fc5f..b802449e88 100644 --- a/lib/models/remote.js +++ b/lib/models/remote.js @@ -1,12 +1,34 @@ export default class Remote { - constructor(name) { + constructor(name, url) { this.name = name; + this.url = url; + + const {isGithubRepo, owner, repo} = githubInfoFromRemote(url); + this.githubRepo = isGithubRepo; + this.owner = owner; + this.repo = repo; } getName() { return this.name; } + getUrl() { + return this.url; + } + + isGithubRepo() { + return this.githubRepo; + } + + getOwner() { + return this.owner; + } + + getRepo() { + return this.repo; + } + getNameOr(fallback) { return this.getName(); } @@ -16,6 +38,25 @@ export default class Remote { } } +function githubInfoFromRemote(remoteUrl) { + // proto login domain owner repo + const regex = /(?:.+:\/\/)?(?:.+@)?github\.com[:/]([^/]+)\/(.+)/; + const match = remoteUrl.match(regex); + if (match) { + return { + isGithubRepo: true, + owner: match[1], + repo: match[2].replace(/\.git$/, ''), + }; + } else { + return { + isGithubRepo: false, + owner: null, + repo: null, + }; + } +} + export const nullRemote = { getName() { return ''; diff --git a/lib/models/repository.js b/lib/models/repository.js index c37ec4f7bc..1e228cc667 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -29,25 +29,6 @@ export class CommitError extends Error { } export default class Repository { - static githubInfoFromRemote(remote) { - // proto login domain owner repo - const regex = /(?:.+:\/\/)?(?:.+@)?github\.com[:/]([^/]+)\/(.+)/; - const match = remote.match(regex); - if (match) { - return { - githubRepo: true, - owner: match[1], - name: match[2].replace(/\.git$/, ''), - }; - } else { - return { - githubRepo: false, - owner: null, - name: null, - }; - } - } - constructor(workingDirectoryPath, gitStrategy = null) { this.workingDirectoryPath = workingDirectoryPath; this.git = gitStrategy || CompositeGitStrategy.create(workingDirectoryPath); diff --git a/test/models/remote.test.js b/test/models/remote.test.js new file mode 100644 index 0000000000..88f794326d --- /dev/null +++ b/test/models/remote.test.js @@ -0,0 +1,41 @@ +import Remote from '../../lib/models/remote'; + +describe('Remote', function() { + it('detects and extracts information from GitHub repository URLs', function() { + const urls = [ + 'git@github.com:atom/github.git', + 'https://github.com/atom/github.git', + 'https://git:pass@github.com/atom/github.git', + 'ssh+https://github.com/atom/github.git', + 'git://github.com/atom/github', + 'ssh://git@github.com:atom/github.git', + ]; + + for (const url of urls) { + const remote = new Remote('origin', url); + + assert.equal(remote.getName(), 'origin'); + assert.equal(remote.getUrl(), url); + assert.isTrue(remote.isGithubRepo()); + assert.equal(remote.getOwner(), 'atom'); + assert.equal(remote.getRepo(), 'github'); + } + }); + + it('detects non-GitHub remotes', function() { + const urls = [ + 'git@gitlab.com:atom/github.git', + 'atom/github', + ]; + + for (const url of urls) { + const remote = new Remote('origin', url); + + assert.equal(remote.getName(), 'origin'); + assert.equal(remote.getUrl(), url); + assert.isFalse(remote.isGithubRepo()); + assert.isNull(remote.getOwner()); + assert.isNull(remote.getRepo()); + } + }); +}); diff --git a/test/models/repository.test.js b/test/models/repository.test.js index 67bebe7efa..bf4a422052 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -27,38 +27,6 @@ describe('Repository', function() { assert.lengthOf(missing, 0); }); - describe('githubInfoFromRemote', function() { - it('returns info about a GitHub repo based on the remote URL', function() { - const atomRepo = { - githubRepo: true, - owner: 'atom', - name: 'github', - }; - - const notARepo = { - githubRepo: false, - owner: null, - name: null, - }; - - const remotes = [ - 'git@github.com:atom/github.git', - 'https://github.com/atom/github.git', - 'https://git:pass@github.com/atom/github.git', - 'ssh+https://github.com/atom/github.git', - 'git://github.com/atom/github', - 'ssh://git@github.com:atom/github.git', - ]; - - for (const remote of remotes) { - assert.deepEqual(Repository.githubInfoFromRemote(remote), atomRepo); - } - - assert.deepEqual(Repository.githubInfoFromRemote('git@gitlab.com:atom/github.git'), notARepo); - assert.deepEqual(Repository.githubInfoFromRemote('atom/github'), notARepo); - }); - }); - describe('init', function() { it('creates a repository in the given dir and returns the repository', async function() { const soonToBeRepositoryPath = fs.realpathSync(temp.mkdirSync()); From b193c76d6cd5b31284c00fdb78ae96da715d4515 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 11:35:58 -0400 Subject: [PATCH 072/161] Helper to get the SHA of a working directory --- test/helpers.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/helpers.js b/test/helpers.js index eb843d86dc..644182f7da 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -38,6 +38,12 @@ export async function cloneRepository(repoName = 'three-files') { return copyCachedRepo(repoName); } +export async function sha(directory) { + const git = new GitShellOutStrategy(directory); + const head = await git.getHeadCommit(); + return head.sha; +} + /* * Initialize an empty repository at a temporary path. */ From c3f3bae62cf93a6fbd5fe9d0c13c2ada714c9aff Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 11:36:20 -0400 Subject: [PATCH 073/161] Update buildRepository() for stateful Repositories --- test/helpers.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/helpers.js b/test/helpers.js index 644182f7da..8f5cf2dcfe 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -86,8 +86,10 @@ export async function getHeadCommitOnRemote(remotePath) { return git.getHeadCommit(); } -export function buildRepository(workingDirPath) { - return Repository.open(workingDirPath); +export async function buildRepository(workingDirPath) { + const repository = new Repository(workingDirPath); + await repository.getLoadPromise(); + return repository; } export function assertDeepPropertyVals(actual, expected) { From 258e57f45584b340b56f29abf59e5e95e5e8f5e9 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 11:37:02 -0400 Subject: [PATCH 074/161] Detect a Repository's state by name --- lib/models/repository.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/models/repository.js b/lib/models/repository.js index 1e228cc667..3cb3a22051 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -120,6 +120,10 @@ export default class Repository { return path.join(this.getWorkingDirectoryPath(), '.git'); } + isInState(stateName) { + return this.state.constructor.name === stateName; + } + // Compound Getters ////////////////////////////////////////////////////////////////////////////////////////////////// // Accessor methods for data derived from other, state-provided getters. From 5afdb22c25b911cfc4caeb477119e42b667fdaa1 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 11:37:24 -0400 Subject: [PATCH 075/161] Delegate a method to respond to filesystem changes --- lib/models/repository.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/models/repository.js b/lib/models/repository.js index 3cb3a22051..e3dfba0e99 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -172,6 +172,7 @@ const delegates = [ 'clone', 'destroy', 'refresh', + 'observeFilesystemChange', 'stageFiles', 'unstageFiles', From b011471f91a5804e39d424c35b4d9cba61cdd2fb Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 11:40:44 -0400 Subject: [PATCH 076/161] Adapt to the use of Real Objects for Branches, Remotes, Commits --- lib/controllers/commit-view-controller.js | 4 +-- lib/controllers/file-patch-controller.js | 2 +- lib/controllers/github-tab-controller.js | 28 +++++++--------- lib/controllers/pr-info-controller.js | 6 ++-- lib/controllers/root-controller.js | 4 +-- lib/controllers/status-bar-tile-controller.js | 20 ++++++------ lib/models/repository-states/present.js | 18 +++++++---- lib/prop-types.js | 24 ++++++++------ lib/views/branch-menu-view.js | 25 ++++++--------- lib/views/branch-view.js | 6 ++-- lib/views/changed-files-count-view.js | 4 +-- lib/views/commit-view.js | 4 +-- lib/views/git-tab-view.js | 2 +- lib/views/push-pull-menu-view.js | 18 +++++------ lib/views/staging-view.js | 2 +- .../commit-view-controller.test.js | 4 ++- test/controllers/git-tab-controller.test.js | 2 +- .../status-bar-tile-controller.test.js | 32 ++++++++++++++----- test/models/repository.test.js | 12 +++---- test/views/commit-view.test.js | 9 ++++-- 20 files changed, 125 insertions(+), 101 deletions(-) diff --git a/lib/controllers/commit-view-controller.js b/lib/controllers/commit-view-controller.js index 5d63054aa2..e75762f698 100644 --- a/lib/controllers/commit-view-controller.js +++ b/lib/controllers/commit-view-controller.js @@ -71,8 +71,8 @@ export default class CommitViewController { let message = this.regularCommitMessage; if (this.props.isAmending) { message = this.amendingCommitMessage; - if (!message.length && this.props.lastCommit) { - message = this.props.lastCommit.message; + if (!message.length && this.props.lastCommit.isPresent()) { + message = this.props.lastCommit.getMessage(); } } else if (!message.length && this.props.mergeMessage) { message = this.props.mergeMessage; diff --git a/lib/controllers/file-patch-controller.js b/lib/controllers/file-patch-controller.js index 1f19f71bfd..2722869510 100644 --- a/lib/controllers/file-patch-controller.js +++ b/lib/controllers/file-patch-controller.js @@ -11,7 +11,7 @@ import FilePatchView from '../views/file-patch-view'; export default class FilePatchController extends React.Component { static propTypes = { activeWorkingDirectory: PropTypes.string, - repository: PropTypes.object, + repository: PropTypes.object.isRequired, commandRegistry: PropTypes.object.isRequired, filePatch: PropTypes.object.isRequired, stagingStatus: PropTypes.oneOf(['unstaged', 'staged']).isRequired, diff --git a/lib/controllers/github-tab-controller.js b/lib/controllers/github-tab-controller.js index bbf3b64ce3..537b340222 100644 --- a/lib/controllers/github-tab-controller.js +++ b/lib/controllers/github-tab-controller.js @@ -4,7 +4,6 @@ import {autobind} from 'core-decorators'; import yubikiri from 'yubikiri'; import RemotePrController from './remote-pr-controller'; -import Repository from '../models/repository'; import GithubLoginModel from '../models/github-login-model'; import ObserveModelDecorator from '../decorators/observe-model'; import {RemotePropType, BranchPropType} from '../prop-types'; @@ -27,9 +26,9 @@ class RemoteSelector extends React.Component {

    {remotes.map(remote => ( -
  • +
  • selectRemote(e, remote)}> - {remote.name} ({remote.info.owner}/{remote.info.name}) + {remote.getName()} ({remote.getOwner()}/{remote.getRepo()})
  • ))} @@ -43,12 +42,9 @@ class RemoteSelector extends React.Component { getModel: props => props.repository, fetchData: repo => { return yubikiri({ - remotes: repo.getRemotes().then(remotes => { - return remotes.map(({name, url}) => ({name, url, info: Repository.githubInfoFromRemote(url)})) - .filter(remote => remote.info.githubRepo); - }), + remotes: repo.getRemotes().then(remotes => remotes.filter(remote => remote.isGithubRepo())), currentBranch: repo.getCurrentBranch(), - selectedRemote: repo.getConfig('atomGithub.currentRemote'), + selectedRemoteName: repo.getConfig('atomGithub.currentRemote'), }); }, }) @@ -57,13 +53,13 @@ export default class GithubTabController extends React.Component { repository: PropTypes.object, remotes: PropTypes.arrayOf(RemotePropType.isRequired), currentBranch: BranchPropType, - selectedRemote: PropTypes.string, + selectedRemoteName: PropTypes.string, } static defaultProps = { remotes: null, currentBranch: {name: '', isDetached: false}, - selectedRemote: null, + selectedRemoteName: null, } constructor(props, context) { @@ -72,15 +68,15 @@ export default class GithubTabController extends React.Component { } render() { - if (!this.props.repository || !this.props.remotes) { + if (!this.props.repository.isPresent() || this.props.remotes.length === 0) { return null; } - if (!this.props.currentBranch || this.props.currentBranch.isDetached) { + if (!this.props.currentBranch.isPresent() || this.props.currentBranch.isDetached()) { return null; } - let remote = this.props.remotes.find(r => r.name === this.props.selectedRemote); + let remote = this.props.remotes.find(r => r.getName() === this.props.selectedRemoteName); let manyRemotesAvailable = false; if (!remote && this.props.remotes.length === 1) { remote = this.props.remotes[0]; @@ -97,13 +93,13 @@ export default class GithubTabController extends React.Component { host="https://api.github.com" loginModel={this.loginModel} remote={remote} - currentBranchName={this.props.currentBranch.name} + currentBranchName={this.props.currentBranch.getName()} /> } {!remote && manyRemotesAvailable && } @@ -128,6 +124,6 @@ export default class GithubTabController extends React.Component { @autobind handleRemoteSelect(e, remote) { e.preventDefault(); - this.props.repository.setConfig('atomGithub.currentRemote', remote.name); + this.props.repository.setConfig('atomGithub.currentRemote', remote.getName()); } } diff --git a/lib/controllers/pr-info-controller.js b/lib/controllers/pr-info-controller.js index 62c461271e..b61a6eccd2 100644 --- a/lib/controllers/pr-info-controller.js +++ b/lib/controllers/pr-info-controller.js @@ -24,7 +24,7 @@ export default class PrInfoController extends React.Component { shouldComponentUpdate(nextProps) { return ( nextProps.token !== this.props.token || - nextProps.hsot !== this.props.host || + nextProps.host !== this.props.host || nextProps.currentBranchName !== this.props.currentBranchName || nextProps.onLogin !== this.props.onLogin || nextProps.remote !== this.props.remote @@ -39,8 +39,8 @@ export default class PrInfoController extends React.Component { } const route = new PrInfoByBranchRoute({ - repoOwner: this.props.remote.info.owner, - repoName: this.props.remote.info.name, + repoOwner: this.props.remote.getOwner(), + repoName: this.props.remote.getName(), branchName: this.props.currentBranchName, }); diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index 68a9f311ac..cfe634e442 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -47,8 +47,8 @@ export default class RootController extends React.Component { activeWorkingDirectory: PropTypes.string, createRepositoryForProjectPath: PropTypes.func, cloneRepositoryForProjectPath: PropTypes.func, - repository: PropTypes.object, - resolutionProgress: PropTypes.object, + repository: PropTypes.object.isRequired, + resolutionProgress: PropTypes.object.isRequired, statusBar: PropTypes.object, switchboard: PropTypes.instanceOf(Switchboard), savedState: PropTypes.object, diff --git a/lib/controllers/status-bar-tile-controller.js b/lib/controllers/status-bar-tile-controller.js index 1451d04ab7..f41f1a2539 100644 --- a/lib/controllers/status-bar-tile-controller.js +++ b/lib/controllers/status-bar-tile-controller.js @@ -35,9 +35,9 @@ import {autobind} from 'core-decorators'; return changedFiles.size; }), - remoteName: async query => repository.getRemoteForBranch((await query.currentBranch).name), - aheadCount: async query => repository.getAheadCount((await query.currentBranch).name), - behindCount: async query => repository.getBehindCount((await query.currentBranch).name), + remoteName: async query => repository.getRemoteForBranch((await query.currentBranch).getName()), + aheadCount: async query => repository.getAheadCount((await query.currentBranch).getName()), + behindCount: async query => repository.getBehindCount((await query.currentBranch).getName()), }); }, }) @@ -47,9 +47,9 @@ export default class StatusBarTileController extends React.Component { notificationManager: PropTypes.object.isRequired, commandRegistry: PropTypes.object.isRequired, tooltips: PropTypes.object.isRequired, - repository: PropTypes.object, - currentBranch: BranchPropType, - branches: PropTypes.arrayOf(PropTypes.string), + repository: PropTypes.object.isRequired, + currentBranch: BranchPropType.isRequired, + branches: PropTypes.arrayOf(BranchPropType).isRequired, remoteName: PropTypes.string, aheadCount: PropTypes.number, behindCount: PropTypes.number, @@ -73,7 +73,7 @@ export default class StatusBarTileController extends React.Component { render() { const repoProps = { - currentBranch: this.props.currentBranch || {name: '', isDetached: false}, + currentBranch: this.props.currentBranch, branches: this.props.branches, remoteName: this.props.remoteName, aheadCount: this.props.aheadCount, @@ -179,17 +179,17 @@ export default class StatusBarTileController extends React.Component { @autobind async push(options) { await this.setInProgressWhile(() => { - return this.props.repository.push(this.props.currentBranch.name, options); + return this.props.repository.push(this.props.currentBranch.getName(), options); }, {push: true}); } @autobind async pull() { - await this.setInProgressWhile(() => this.props.repository.pull(this.props.currentBranch.name), {pull: true}); + await this.setInProgressWhile(() => this.props.repository.pull(this.props.currentBranch.getName()), {pull: true}); } @autobind async fetch() { - await this.setInProgressWhile(() => this.props.repository.fetch(this.props.currentBranch.name), {fetch: true}); + await this.setInProgressWhile(() => this.props.repository.fetch(this.props.currentBranch.getName()), {fetch: true}); } } diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index c22f1cf937..b93ab659cc 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -7,6 +7,9 @@ import FilePatch from '../file-patch'; import Hunk from '../hunk'; import HunkLine from '../hunk-line'; import DiscardHistory from '../discard-history'; +import Branch from '../branch'; +import Remote from '../remote'; +import Commit from '../commit'; class Cache { constructor() { @@ -324,8 +327,9 @@ export default class Present extends State { // Commit access getLastCommit() { - return this.cache.getOrSet('last-commit', () => { - return this.git().getHeadCommit(); + return this.cache.getOrSet('last-commit', async () => { + const {sha, message, unbornRef} = await this.git().getHeadCommit(); + return unbornRef ? Commit.createUnborn() : new Commit(sha, message); }); } @@ -338,8 +342,9 @@ export default class Present extends State { } getCurrentBranch() { - return this.cache.getOrSet('current-branch', () => { - return this.git().getCurrentBranch(); + return this.cache.getOrSet('current-branch', async () => { + const {name, isDetached} = await this.git().getCurrentBranch(); + return isDetached ? Branch.createDetached(name) : new Branch(name); }); } @@ -356,8 +361,9 @@ export default class Present extends State { // Remotes getRemotes() { - return this.cache.getOrSet('remotes', () => { - return this.git().getRemotes(); + return this.cache.getOrSet('remotes', async () => { + const remotesInfo = await this.git().getRemotes(); + return remotesInfo.map(({name, url}) => new Remote(name, url)); }); } diff --git a/lib/prop-types.js b/lib/prop-types.js index f95338d935..bce4df5ed5 100644 --- a/lib/prop-types.js +++ b/lib/prop-types.js @@ -11,16 +11,22 @@ export const DOMNodePropType = (props, propName, componentName) => { }; export const RemotePropType = PropTypes.shape({ - name: PropTypes.string, - url: PropTypes.string, - info: PropTypes.shape({ - githubRepo: PropTypes.bool.isRequired, - owner: PropTypes.string, - name: PropTypes.string, - }).isRequired, + getName: PropTypes.func.isRequired, + getUrl: PropTypes.func.isRequired, + isGithubRepo: PropTypes.func.isRequired, + getOwner: PropTypes.func.isRequired, + getRepo: PropTypes.func.isRequired, }); export const BranchPropType = PropTypes.shape({ - name: PropTypes.string.isRequired, - isDetached: PropTypes.bool.isRequired, + getName: PropTypes.func.isRequired, + isDetached: PropTypes.func.isRequired, + isPresent: PropTypes.func.isRequired, +}); + +export const CommitPropType = PropTypes.shape({ + getSha: PropTypes.func.isRequired, + getMessage: PropTypes.func.isRequired, + isUnbornRef: PropTypes.func.isRequired, + isPresent: PropTypes.func.isRequired, }); diff --git a/lib/views/branch-menu-view.js b/lib/views/branch-menu-view.js index 18c6ddecfd..3a8326ab68 100644 --- a/lib/views/branch-menu-view.js +++ b/lib/views/branch-menu-view.js @@ -11,17 +11,12 @@ export default class BranchMenuView extends React.Component { workspace: PropTypes.object.isRequired, commandRegistry: PropTypes.object.isRequired, notificationManager: PropTypes.object.isRequired, - branches: PropTypes.arrayOf(PropTypes.string), - currentBranch: BranchPropType, + branches: PropTypes.arrayOf(BranchPropType).isRequired, + currentBranch: BranchPropType.isRequired, checkout: PropTypes.func, } static defaultProps = { - branches: [], - currentBranch: { - name: '', - isDetached: true, - }, checkout: () => Promise.resolve(), } @@ -35,10 +30,10 @@ export default class BranchMenuView extends React.Component { } render() { - const currentBranchName = this.state.checkingOutBranch || this.props.currentBranch.name; - const branches = this.props.branches; - if (this.state.checkingOutBranch && this.props.branches.indexOf(this.state.checkingOutBranch) === -1) { - branches.push(this.state.checkingOutBranch); + const currentBranchName = this.state.checkingOutBranch || this.props.currentBranch.getName(); + const branchNames = this.props.branches.map(branch => branch.getName()); + if (this.state.checkingOutBranch && branchNames.indexOf(this.state.checkingOutBranch) === -1) { + branchNames.push(this.state.checkingOutBranch); } const newBranchEditor = ( @@ -60,11 +55,11 @@ export default class BranchMenuView extends React.Component { className="github-BranchMenuView-item github-BranchMenuView-select input-select" onChange={this.didSelectItem} value={currentBranchName}> - {this.props.currentBranch.isDetached && - + {this.props.currentBranch.isDetached() && + } - {branches.map(branch => { - return ; + {branchNames.map(branchName => { + return ; })} ); diff --git a/lib/views/branch-view.js b/lib/views/branch-view.js index 06d70a96b4..e624e4dd49 100644 --- a/lib/views/branch-view.js +++ b/lib/views/branch-view.js @@ -5,18 +5,18 @@ import {BranchPropType} from '../prop-types'; export default class BranchView extends React.Component { static propTypes = { - currentBranch: BranchPropType, + currentBranch: BranchPropType.isRequired, } render() { const classNames = cx( - 'github-branch', 'inline-block', {'github-branch-detached': this.props.currentBranch.isDetached}, + 'github-branch', 'inline-block', {'github-branch-detached': this.props.currentBranch.isDetached()}, ); return (
    { this.element = e; }}> - {this.props.currentBranch.name} + {this.props.currentBranch.getName()}
    ); } diff --git a/lib/views/changed-files-count-view.js b/lib/views/changed-files-count-view.js index 543855ea27..61eabe822f 100644 --- a/lib/views/changed-files-count-view.js +++ b/lib/views/changed-files-count-view.js @@ -3,8 +3,8 @@ import PropTypes from 'prop-types'; export default class ChangedFilesCountView extends React.Component { static propTypes = { - changedFilesCount: PropTypes.number, - didClick: PropTypes.func, + changedFilesCount: PropTypes.number.isRequired, + didClick: PropTypes.func.isRequired, } static defaultProps = { diff --git a/lib/views/commit-view.js b/lib/views/commit-view.js index eb57d54cef..6a5c85dc1b 100644 --- a/lib/views/commit-view.js +++ b/lib/views/commit-view.js @@ -50,7 +50,7 @@ export default class CommitView { } const showAbortMergeButton = this.props.isMerging || null; - const showAmendBox = (!this.props.isMerging && !this.props.lastCommit.unbornRef) || null; + const showAmendBox = (!this.props.isMerging && !this.props.lastCommit.isUnbornRef()) || null; return (
    @@ -133,7 +133,7 @@ export default class CommitView { commitButtonText() { if (this.props.isAmending) { - return `Amend commit (${shortenSha(this.props.lastCommit.sha)})`; + return `Amend commit (${shortenSha(this.props.lastCommit.getSha())})`; } else { if (this.props.branchName) { return `Commit to ${this.props.branchName}`; diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js index b39d7b697b..7155fa0922 100644 --- a/lib/views/git-tab-view.js +++ b/lib/views/git-tab-view.js @@ -34,7 +34,7 @@ export default class GitTabView { } render() { - if (!this.props.repository) { + if (this.props.repository.isEmpty()) { return (
    diff --git a/lib/views/push-pull-menu-view.js b/lib/views/push-pull-menu-view.js index 5efd975df4..0064ec6d14 100644 --- a/lib/views/push-pull-menu-view.js +++ b/lib/views/push-pull-menu-view.js @@ -7,15 +7,15 @@ import {BranchPropType} from '../prop-types'; export default class PushPullMenuView extends React.Component { static propTypes = { + notificationManager: PropTypes.object.isRequired, + currentBranch: BranchPropType.isRequired, inProgress: PropTypes.bool, - currentBranch: BranchPropType, remoteName: PropTypes.string, aheadCount: PropTypes.number, behindCount: PropTypes.number, - notificationManager: PropTypes.object, - fetch: PropTypes.func, - push: PropTypes.func, - pull: PropTypes.func, + fetch: PropTypes.func.isRequired, + push: PropTypes.func.isRequired, + pull: PropTypes.func.isRequired, } static defaultProps = { @@ -61,8 +61,8 @@ export default class PushPullMenuView extends React.Component { render() { const errorMessage = this.getErrorMessage(); const fetchDisabled = !this.props.remoteName || this.props.inProgress; - const pullDisabled = !this.props.remoteName || this.props.currentBranch.isDetached || this.props.inProgress; - const pushDisabled = this.props.currentBranch.isDetached || this.props.inProgress; + const pullDisabled = !this.props.remoteName || this.props.currentBranch.isDetached() || this.props.inProgress; + const pushDisabled = this.props.currentBranch.isDetached() || this.props.inProgress; return (
    @@ -99,12 +99,12 @@ export default class PushPullMenuView extends React.Component { return this.state.errorMessage; } - if (this.props.currentBranch.isDetached) { + if (this.props.currentBranch.isDetached()) { return 'Note: you are not on a branch. Please create one if you wish to push your work anywhere.'; } if (!this.props.remoteName) { - return `Note: No remote detected for branch ${this.props.currentBranch.name}. ` + + return `Note: No remote detected for branch ${this.props.currentBranch.getName()}. ` + 'Pushing will set up a remote tracking branch on remote repo "origin"'; } diff --git a/lib/views/staging-view.js b/lib/views/staging-view.js index 06e53b354b..2379a21524 100644 --- a/lib/views/staging-view.js +++ b/lib/views/staging-view.js @@ -411,7 +411,7 @@ export default class StagingView { Staged Changes { this.props.isAmending - ? ` (amending ${shortenSha(this.props.lastCommit.sha)})` + ? ` (amending ${shortenSha(this.props.lastCommit.getSha())})` : '' } diff --git a/test/controllers/commit-view-controller.test.js b/test/controllers/commit-view-controller.test.js index e29206c8bd..32b00d12df 100644 --- a/test/controllers/commit-view-controller.test.js +++ b/test/controllers/commit-view-controller.test.js @@ -1,3 +1,5 @@ +import Commit from '../../lib/models/commit'; + import CommitViewController from '../../lib/controllers/commit-view-controller'; import {cloneRepository, buildRepository} from '../helpers'; @@ -8,7 +10,7 @@ describe('CommitViewController', function() { atomEnvironment = global.buildAtomEnvironment(); commandRegistry = atomEnvironment.commands; - lastCommit = {sha: 'a1e23fd45', message: 'last commit message', unbornRef: false}; + lastCommit = new Commit('a1e23fd45', 'last commit message'); }); afterEach(function() { diff --git a/test/controllers/git-tab-controller.test.js b/test/controllers/git-tab-controller.test.js index 6a408e8471..663f4fd9b3 100644 --- a/test/controllers/git-tab-controller.test.js +++ b/test/controllers/git-tab-controller.test.js @@ -456,7 +456,7 @@ describe('GitTabController', function() { await commitView.commit(); await controller.getLastModelDataRefreshPromise(); - assert.equal((await repository.getLastCommit()).message, 'Make it so'); + assert.equal((await repository.getLastCommit()).getMessage(), 'Make it so'); }); it('can stage merge conflict files', async function() { diff --git a/test/controllers/status-bar-tile-controller.test.js b/test/controllers/status-bar-tile-controller.test.js index ef7e8f43e9..8feedd44ff 100644 --- a/test/controllers/status-bar-tile-controller.test.js +++ b/test/controllers/status-bar-tile-controller.test.js @@ -97,17 +97,23 @@ describe('StatusBarTileController', function() { const branches = Array.from(tip.getElementsByTagName('option'), e => e.innerHTML); assert.deepEqual(branches, ['branch', 'master']); - assert.deepEqual(await repository.getCurrentBranch(), {name: 'master', isDetached: false}); + const branch0 = await repository.getCurrentBranch(); + assert.equal(branch0.getName(), 'master'); + assert.isFalse(branch0.isDetached()); assert.equal(tip.querySelector('select').value, 'master'); selectOption(tip, 'branch'); - assert.deepEqual(await repository.getCurrentBranch(), {name: 'branch', isDetached: false}); + const branch1 = await repository.getCurrentBranch(); + assert.equal(branch1.getName(), 'branch'); + assert.isFalse(branch1.isDetached()); assert.equal(tip.querySelector('select').value, 'branch'); await wrapper.instance().refreshModelData(); assert.equal(tip.querySelector('select').value, 'branch'); selectOption(tip, 'master'); - assert.deepEqual(await repository.getCurrentBranch(), {name: 'master', isDetached: false}); + const branch2 = await repository.getCurrentBranch(); + assert.equal(branch2.getName(), 'master'); + assert.isFalse(branch2.isDetached()); assert.equal(tip.querySelector('select').value, 'master'); await wrapper.instance().refreshModelData(); assert.equal(tip.querySelector('select').value, 'master'); @@ -130,7 +136,9 @@ describe('StatusBarTileController', function() { const tip = getTooltipNode(wrapper, BranchView); - assert.deepEqual(await repository.getCurrentBranch(), {name: 'branch', isDetached: false}); + const branch0 = await repository.getCurrentBranch(); + assert.equal(branch0.getName(), 'branch'); + assert.isFalse(branch0.isDetached()); assert.equal(tip.querySelector('select').value, 'branch'); sinon.stub(notificationManager, 'addError'); @@ -162,7 +170,9 @@ describe('StatusBarTileController', function() { const branches = Array.from(tip.querySelectorAll('option'), option => option.value); assert.deepEqual(branches, ['master']); - assert.deepEqual(await repository.getCurrentBranch(), {name: 'master', isDetached: false}); + const branch0 = await repository.getCurrentBranch(); + assert.equal(branch0.getName(), 'master'); + assert.isFalse(branch0.isDetached()); assert.equal(tip.querySelector('select').value, 'master'); tip.querySelector('button').click(); @@ -179,7 +189,9 @@ describe('StatusBarTileController', function() { }); assert.equal(tip.querySelector('select').value, 'new-branch'); - assert.deepEqual(await repository.getCurrentBranch(), {name: 'new-branch', isDetached: false}); + const branch1 = await repository.getCurrentBranch(); + assert.equal(branch1.getName(), 'new-branch'); + assert.isFalse(branch1.isDetached()); assert.lengthOf(tip.querySelectorAll('.github-BranchMenuView-editor'), 0); assert.lengthOf(tip.querySelectorAll('select'), 1); @@ -198,7 +210,9 @@ describe('StatusBarTileController', function() { const branches = Array.from(tip.getElementsByTagName('option'), option => option.value); assert.deepEqual(branches, ['branch', 'master']); - assert.deepEqual(await repository.getCurrentBranch(), {name: 'branch', isDetached: false}); + const branch0 = await repository.getCurrentBranch(); + assert.equal(branch0.getName(), 'branch'); + assert.isFalse(branch0.isDetached()); assert.equal(tip.querySelector('select').value, 'branch'); tip.querySelector('button').click(); @@ -210,7 +224,9 @@ describe('StatusBarTileController', function() { assert.equal(notificationArgs[0], 'Cannot create branch'); assert.match(notificationArgs[1].description, /already exists/); - assert.deepEqual(await repository.getCurrentBranch(), {name: 'branch', isDetached: false}); + const branch1 = await repository.getCurrentBranch(); + assert.equal(branch1.getName(), 'branch'); + assert.isFalse(branch1.isDetached()); assert.equal(tip.querySelector('select').value, 'branch'); }); }); diff --git a/test/models/repository.test.js b/test/models/repository.test.js index bf4a422052..f765303896 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -275,7 +275,7 @@ describe('Repository', function() { const repo = new Repository(workingDirPath); await repo.getLoadPromise(); - assert.equal((await repo.getLastCommit()).message, 'Initial commit'); + assert.equal((await repo.getLastCommit()).getMessage(), 'Initial commit'); fs.writeFileSync(path.join(workingDirPath, 'subdir-1', 'a.txt'), 'qux\nfoo\nbar\n', 'utf8'); const unstagedPatch1 = await repo.getFilePatchForPath(path.join('subdir-1', 'a.txt')); @@ -283,7 +283,7 @@ describe('Repository', function() { repo.refresh(); await repo.applyPatchToIndex(unstagedPatch1); await repo.commit('Commit 1'); - assert.equal((await repo.getLastCommit()).message, 'Commit 1'); + assert.equal((await repo.getLastCommit()).getMessage(), 'Commit 1'); repo.refresh(); assert.deepEqual(await repo.getStagedChanges(), []); const unstagedChanges = await repo.getUnstagedChanges(); @@ -292,7 +292,7 @@ describe('Repository', function() { const unstagedPatch2 = await repo.getFilePatchForPath(path.join('subdir-1', 'a.txt')); await repo.applyPatchToIndex(unstagedPatch2); await repo.commit('Commit 2'); - assert.equal((await repo.getLastCommit()).message, 'Commit 2'); + assert.equal((await repo.getLastCommit()).getMessage(), 'Commit 2'); repo.refresh(); assert.deepEqual(await repo.getStagedChanges(), []); assert.deepEqual(await repo.getUnstagedChanges(), []); @@ -334,7 +334,7 @@ describe('Repository', function() { } assert.equal(await repository.isMerging(), true); - assert.equal((await repository.getLastCommit()).toString(), mergeBase.toString()); + assert.equal((await repository.getLastCommit()).getSha(), mergeBase.getSha()); }); it('wraps the commit message body at 72 characters', async function() { @@ -350,7 +350,7 @@ describe('Repository', function() { 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.', ].join('\n')); - const message = (await repo.getLastCommit()).message; + const message = (await repo.getLastCommit()).getMessage(); assert.deepEqual(message.split('\n'), [ 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor', '', @@ -374,7 +374,7 @@ describe('Repository', function() { '# other stuff', ].join('\n')); - assert.deepEqual((await repo.getLastCommit()).message, 'Make a commit'); + assert.deepEqual((await repo.getLastCommit()).getMessage(), 'Make a commit'); }); }); diff --git a/test/views/commit-view.test.js b/test/views/commit-view.test.js index 5c42e20417..3ad0253eac 100644 --- a/test/views/commit-view.test.js +++ b/test/views/commit-view.test.js @@ -2,6 +2,7 @@ import {cloneRepository, buildRepository} from '../helpers'; import etch from 'etch'; import until from 'test-until'; +import Commit from '../../lib/models/commit'; import CommitView from '../../lib/views/commit-view'; describe('CommitView', function() { @@ -11,7 +12,7 @@ describe('CommitView', function() { atomEnv = global.buildAtomEnvironment(); commandRegistry = atomEnv.commands; - lastCommit = {sha: '1234abcd', message: 'commit message', unbornRef: false}; + lastCommit = new Commit('1234abcd', 'commit message'); }); afterEach(function() { @@ -192,10 +193,12 @@ describe('CommitView', function() { describe('amending', function() { it('calls props.setAmending() when the box is checked or unchecked', function() { + const previousCommit = new Commit('111', "previous commit's message"); + const setAmending = sinon.spy(); const view = new CommitView({ commandRegistry, - stagedChangesExist: false, lastCommit: {message: 'previous commit\'s message'}, setAmending, + stagedChangesExist: false, lastCommit: previousCommit, setAmending, }); const {amend} = view.refs; @@ -210,7 +213,7 @@ describe('CommitView', function() { const view = new CommitView({ commandRegistry, stagedChangesExist: false, - lastCommit: {unbornRef: true}, + lastCommit: Commit.createUnborn(), }); assert.isUndefined(view.refs.amend); }); From 64843d5c4d0e69d96d3f6a3a74019093b6541167 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 11:44:53 -0400 Subject: [PATCH 077/161] Begin all ResolutionProgress instances empty --- .../repository-conflict-controller.js | 2 +- lib/models/conflicts/resolution-progress.js | 24 ++++++++++++------- lib/views/staging-view.js | 2 +- .../editor-conflict-controller.test.js | 2 +- test/controllers/git-tab-controller.test.js | 4 ++-- .../conflicts/resolution-progress.test.js | 18 +++++++++----- 6 files changed, 33 insertions(+), 19 deletions(-) diff --git a/lib/controllers/repository-conflict-controller.js b/lib/controllers/repository-conflict-controller.js index 965a0bb1aa..758b366fe0 100644 --- a/lib/controllers/repository-conflict-controller.js +++ b/lib/controllers/repository-conflict-controller.js @@ -34,7 +34,7 @@ export default class RepositoryConflictController extends React.Component { }; static defaultProps = { - resolutionProgress: ResolutionProgress.empty(), + resolutionProgress: new ResolutionProgress(), mergeConflictPaths: [], isRebasing: false, refreshResolutionProgress: () => {}, diff --git a/lib/models/conflicts/resolution-progress.js b/lib/models/conflicts/resolution-progress.js index d135cc7dc0..bfd7664d83 100644 --- a/lib/models/conflicts/resolution-progress.js +++ b/lib/models/conflicts/resolution-progress.js @@ -1,21 +1,29 @@ import {Emitter} from 'event-kit'; export default class ResolutionProgress { - static empty() { - return new ResolutionProgress('', {}); - } - - constructor(revision, savedState) { + constructor() { this.emitter = new Emitter(); - this.revision = revision; + this.revision = ''; this.markerCountByPath = new Map(); + this.loaded = false; + } + + load(revision, savedState) { + this.revision = revision; if (savedState.revision === this.revision) { - Object.keys(savedState.paths || {}).forEach(path => { - this.markerCountByPath.set(path, savedState.paths[path]); + const paths = Object.keys(savedState.paths || {}); + paths.forEach(filePath => { + this.markerCountByPath.set(filePath, savedState.paths[filePath]); }); + + if (paths.length > 0) { + this.didUpdate(); + } } + + this.loaded = true; } didUpdate() { diff --git a/lib/views/staging-view.js b/lib/views/staging-view.js index 2379a21524..7a6a419983 100644 --- a/lib/views/staging-view.js +++ b/lib/views/staging-view.js @@ -443,7 +443,7 @@ export default class StagingView { if (mergeConflicts && mergeConflicts.length > 0) { const selectedItems = this.selection.getSelectedItems(); - const resolutionProgress = this.resolutionProgressObserver.getActiveModel() || ResolutionProgress.empty(); + const resolutionProgress = this.resolutionProgressObserver.getActiveModel() || new ResolutionProgress(); const anyUnresolved = mergeConflicts .map(conflict => path.join(this.props.workingDirectoryPath, conflict.filePath)) .some(conflictPath => resolutionProgress.getRemaining(conflictPath) !== 0); diff --git a/test/controllers/editor-conflict-controller.test.js b/test/controllers/editor-conflict-controller.test.js index e245834558..70c82f9b93 100644 --- a/test/controllers/editor-conflict-controller.test.js +++ b/test/controllers/editor-conflict-controller.test.js @@ -41,7 +41,7 @@ describe('EditorConflictController', function() { commandRegistry = atomEnv.commands; refreshResolutionProgress = sinon.spy(); - resolutionProgress = ResolutionProgress.empty(); + resolutionProgress = new ResolutionProgress(); }); afterEach(function() { diff --git a/test/controllers/git-tab-controller.test.js b/test/controllers/git-tab-controller.test.js index 663f4fd9b3..cfaa611b75 100644 --- a/test/controllers/git-tab-controller.test.js +++ b/test/controllers/git-tab-controller.test.js @@ -23,7 +23,7 @@ describe('GitTabController', function() { workspaceElement = atomEnvironment.views.getView(workspace); - resolutionProgress = ResolutionProgress.empty(); + resolutionProgress = new ResolutionProgress(); refreshResolutionProgress = sinon.spy(); }); @@ -120,7 +120,7 @@ describe('GitTabController', function() { const repository = await buildRepository(workdirPath); await assert.isRejected(repository.git.merge('origin/branch')); - const rp = ResolutionProgress.empty(); + const rp = new ResolutionProgress(); rp.reportMarkerCount(path.join(workdirPath, 'added-to-both.txt'), 5); const controller = new GitTabController({ diff --git a/test/models/conflicts/resolution-progress.test.js b/test/models/conflicts/resolution-progress.test.js index 874433ed0c..6f71983071 100644 --- a/test/models/conflicts/resolution-progress.test.js +++ b/test/models/conflicts/resolution-progress.test.js @@ -2,12 +2,14 @@ import ResolutionProgress from '../../../lib/models/conflicts/resolution-progres describe('ResolutionProgress', function() { it('reports undefined for any path that has not reported progress yet', function() { - const progress = new ResolutionProgress('1234abcd', {}); + const progress = new ResolutionProgress(); + progress.load('1234abcd', {}); assert.isUndefined(progress.getRemaining('path/to/file.txt')); }); it('accepts reports of unresolved conflict counts', function() { - const progress = new ResolutionProgress('1234abcd', {}); + const progress = new ResolutionProgress(); + progress.load('1234abcd', {}); progress.reportMarkerCount('path/to/file.txt', 3); assert.equal(progress.getRemaining('path/to/file.txt'), 3); @@ -17,7 +19,8 @@ describe('ResolutionProgress', function() { let payload; beforeEach(function() { - const progress0 = new ResolutionProgress('1234abcd', {}); + const progress0 = new ResolutionProgress(); + progress0.load('1234abcd', {}); progress0.reportMarkerCount('path/to/file0.txt', 3); progress0.reportMarkerCount('path/to/file1.txt', 4); @@ -25,14 +28,16 @@ describe('ResolutionProgress', function() { }); it('restores data from the same revision', function() { - const progress1 = new ResolutionProgress('1234abcd', payload); + const progress1 = new ResolutionProgress(); + progress1.load('1234abcd', payload); assert.equal(progress1.getRemaining('path/to/file0.txt'), 3); assert.equal(progress1.getRemaining('path/to/file1.txt'), 4); assert.isFalse(progress1.isEmpty()); }); it('restores an empty object for a different revision', function() { - const progress2 = new ResolutionProgress('abcd1234', payload); + const progress2 = new ResolutionProgress(); + progress2.load('abcd1234', payload); assert.isUndefined(progress2.getRemaining('path/to/file0.txt')); assert.isUndefined(progress2.getRemaining('path/to/file1.txt')); @@ -44,7 +49,8 @@ describe('ResolutionProgress', function() { let progress, didUpdateSpy; beforeEach(function() { - progress = new ResolutionProgress('1234abcd', {}); + progress = new ResolutionProgress(); + progress.load('1234abcd', {}); progress.reportMarkerCount('path/file0.txt', 4); didUpdateSpy = sinon.spy(); From a2e01f0fb1b3000b7ac394f231669b678c3d0e4f Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 11:45:09 -0400 Subject: [PATCH 078/161] Utility methods on change observers --- lib/models/file-system-change-observer.js | 13 +++++++++++++ lib/models/workspace-change-observer.js | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/lib/models/file-system-change-observer.js b/lib/models/file-system-change-observer.js index 870a904048..1a9624119d 100644 --- a/lib/models/file-system-change-observer.js +++ b/lib/models/file-system-change-observer.js @@ -10,6 +10,7 @@ export default class FileSystemChangeObserver { this.emitter = new Emitter(); this.repository = repository; this.logger = new EventLogger('FileSystemChangeObserver'); + this.started = false; } async start() { @@ -24,6 +25,18 @@ export default class FileSystemChangeObserver { await this.stopCurrentFileWatcher(); } + isStarted() { + return this.started; + } + + didChange(payload) { + this.emitter.emit('did-change', payload); + } + + didChangeWorkdirOrHead() { + this.emitter.emit('did-change-workdir-or-head'); + } + onDidChange(callback) { return this.emitter.on('did-change', callback); } diff --git a/lib/models/workspace-change-observer.js b/lib/models/workspace-change-observer.js index 2b3a0ac4fa..b920d0e74e 100644 --- a/lib/models/workspace-change-observer.js +++ b/lib/models/workspace-change-observer.js @@ -14,6 +14,7 @@ export default class WorkspaceChangeObserver { this.emitter = new Emitter(); this.disposables = new CompositeDisposable(); this.logger = new EventLogger('WorkspaceChangeObserver'); + this.started = false; } async start() { @@ -41,6 +42,18 @@ export default class WorkspaceChangeObserver { await this.stopCurrentFileWatcher(); } + isStarted() { + return this.started; + } + + didChange(payload) { + this.emitter.emit('did-change', payload); + } + + didChangeWorkdirOrHead() { + this.emitter.emit('did-change-workdir-or-head'); + } + onDidChange(callback) { return this.emitter.on('did-change', callback); } From 9f48f95cc389334c983bf55c29b8619a6c014e8a Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 11:45:18 -0400 Subject: [PATCH 079/161] Register the Destroyed state --- lib/models/repository-states/destroyed.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/models/repository-states/destroyed.js b/lib/models/repository-states/destroyed.js index cf48c7b252..5144f35bba 100644 --- a/lib/models/repository-states/destroyed.js +++ b/lib/models/repository-states/destroyed.js @@ -14,3 +14,5 @@ export default class Destroyed extends State { // No-op to destroy twice } } + +State.register(Destroyed); From 9f386d2ba01589b5b42807b6d9b2e78d0d9b4ad8 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 11:45:34 -0400 Subject: [PATCH 080/161] Default observeFilesystemChange() to a refresh() --- lib/models/repository-states/state.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index e0dd1afc30..6ea7313cbb 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -81,6 +81,11 @@ export default class State { // No-op } + @shouldDelegate + observeFilesystemChange() { + this.refresh(); + } + // Git operations //////////////////////////////////////////////////////////////////////////////////////////////////// // These default to rejecting a Promise with an error stating that the operation is not supported in the current // state. From caf499bfc51db7ec91a142fd2f2d1240b41f60f2 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 11:47:27 -0400 Subject: [PATCH 081/161] WorkdirContext no longer needs to defer state --- lib/models/workdir-context.js | 214 +++++++++++----------------- test/models/workdir-context.test.js | 156 +++++++++----------- 2 files changed, 157 insertions(+), 213 deletions(-) diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index 408a24232a..737daa4ded 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -1,48 +1,12 @@ import {Emitter, CompositeDisposable} from 'event-kit'; +import {autobind} from 'core-decorators'; import Repository from './repository'; import ResolutionProgress from './conflicts/resolution-progress'; import FileSystemChangeObserver from './file-system-change-observer'; import WorkspaceChangeObserver from './workspace-change-observer'; -// Provide both synchronous and asynchronous access to a value. -class Deferred { - constructor(value = null) { - if (value !== null) { - this.value = value; - this.promise = Promise.resolve(value); - this.resolve = () => { - throw new Error('Attempt to resolve Deferred with concrete value'); - }; - this.reject = () => { - throw new Error('Attempt to reject Deferred with concrete value'); - }; - } else { - this.value = null; - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - } - } - - hasValue() { - return this.value !== null; - } - - setValue(value) { - this.value = value; - this.resolve(value); - } - - getValue() { - return this.value; - } - - getPromise() { - return this.promise; - } -} +const DORMANT = {}; /* * Bundle of model objects associated with a git working directory. @@ -64,69 +28,60 @@ export default class WorkdirContext { */ constructor(directory, options = {}) { this.directory = directory; - this.options = options; - - this.repositoryHolder = new Deferred(this.options.repository || null); - this.changeObserverHolder = new Deferred(); - this.resolutionProgressHolder = new Deferred(); + const {repository, window: theWindow, workspace, promptCallback} = options; + this.savedResolutionProgress = options.savedResolutionProgress || {}; this.destroyed = false; this.emitter = new Emitter(); this.subs = new CompositeDisposable(); - } - /* - * Initialize all model objects associated with this git working directory. Return a Promise that will resolve when - * all models are available and ready, including the filesystem watcher. Outside of test cases, it's generally - * better to not await this Promise and instead await the `getXyzPromise()` of the models that you need. - */ - async create() { - if (this.destroyed) { - return; - } + this.repository = repository || new Repository(directory); + this.observer = process.platform === 'linux' + ? new WorkspaceChangeObserver(theWindow, workspace, this.repository) + : new FileSystemChangeObserver(this.repository); + this.resolutionProgress = new ResolutionProgress(); - let repo; - if (!this.repositoryHolder.hasValue()) { - repo = await Repository.open(this.directory); - this.repositoryHolder.setValue(repo); - } else { - repo = this.getRepository(); + if (promptCallback) { + this.repository.setPromptCallback(promptCallback); } - if (!repo) { - this.changeObserverHolder.setValue(null); - this.resolutionProgressHolder.setValue(null); - return; - } + if (options.dormant !== DORMANT) { + // Wire up event forwarding among models + this.subs.add(this.repository.onDidChangeState(this.repositoryChangedState)); + this.subs.add(this.observer.onDidChange(payload => this.repository.observeFilesystemChange(payload))); + this.subs.add(this.observer.onDidChangeWorkdirOrHead(() => this.emitter.emit('did-change-workdir-or-head'))); - if (this.options.promptCallback) { - repo.setPromptCallback(this.options.promptCallback); + // If a pre-loaded Repository was provided, broadcast an initial state change event. + if (this.repository) { + this.repositoryChangedState({from: null, to: this.repository.state}); + } } + } - // Event forwarding from repository - this.subs.add(repo.onDidDestroy(() => this.emitter.emit('did-destroy-repository'))); - this.subs.add(repo.onDidUpdate(() => this.emitter.emit('did-update-repository'))); - - const changeObserver = process.platform === 'linux' - ? new WorkspaceChangeObserver(this.options.window, this.options.workspace, repo) - : new FileSystemChangeObserver(repo); - - // Event forwarding from change observer - this.subs.add(changeObserver.onDidChange(() => repo.refresh())); - this.subs.add(changeObserver.onDidChangeWorkdirOrHead(() => this.emitter.emit('did-change-workdir-or-head'))); + /** + * Respond to changes in `Repository` state. Load resolution progress and start the change observer when it becomes + * present. Stop the change observer when it is destroyed. Re-broadcast the event to context subscribers + * regardless. + * + * The ResolutionProgress will be loaded before the change event is re-broadcast, but change observer modifications + * will not be complete. + */ + @autobind + async repositoryChangedState(payload) { + if (this.destroyed) { + return; + } - this.changeObserverHolder.setValue(changeObserver); + if (this.repository.isPresent()) { + this.observer.start().then(() => this.emitter.emit('did-start-observer')); - const populateResolutionProgress = async () => { - const commit = await repo.getLastCommit(); - const progress = new ResolutionProgress(commit.sha, this.options.savedResolutionProgress || {}); - this.resolutionProgressHolder.setValue(progress); - }; + const head = await this.repository.getLastCommit(); + this.resolutionProgress.load(head.getSha(), this.savedResolutionProgress); + } else if (this.repository.isDestroyed()) { + this.observer.destroy(); + } - await Promise.all([ - populateResolutionProgress(), - changeObserver.start(), - ]); + this.emitter.emit('did-change-repository-state', payload); } isPresent() { @@ -137,13 +92,20 @@ export default class WorkdirContext { return this.destroyed; } - /* - * Subscribe to the filesystem observer event. - */ + // Event subscriptions + + onDidStartObserver(callback) { + return this.emitter.on('did-start-observer', callback); + } + onDidChangeWorkdirOrHead(callback) { return this.emitter.on('did-change-workdir-or-head', callback); } + onDidChangeRepositoryState(callback) { + return this.emitter.on('did-change-repository-state', callback); + } + onDidUpdateRepository(callback) { return this.emitter.on('did-update-repository', callback); } @@ -152,32 +114,48 @@ export default class WorkdirContext { return this.emitter.on('did-destroy-repository', callback); } - getWorkingDirectory() { - return this.directory; + /** + * Return a Promise that will resolve the next time that a Repository transitions to the requested state. Most + * useful for test cases; most callers should prefer subscribing to `onDidChangeRepositoryState`. + */ + getRepositoryStatePromise(stateName) { + return new Promise(resolve => { + const sub = this.onDidChangeRepositoryState(() => { + if (this.repository.isInState(stateName)) { + resolve(); + sub.dispose(); + } + }); + }); } - getRepository() { - return this.repositoryHolder.getValue(); + /** + * Return a Promise that will resolve the next time that a ChangeObserver successfully starts. Most useful for + * test cases. + */ + getObserverStartedPromise() { + return new Promise(resolve => { + const sub = this.onDidStartObserver(() => { + resolve(); + sub.dispose(); + }); + }); } - getRepositoryPromise() { - return this.repositoryHolder.getPromise(); + getWorkingDirectory() { + return this.directory; } - getChangeObserver() { - return this.changeObserverHolder.getValue(); + getRepository() { + return this.repository; } - getChangeObserverPromise() { - return this.changeObserverHolder.getPromise(); + getChangeObserver() { + return this.observer; } getResolutionProgress() { - return this.resolutionProgressHolder.getValue(); - } - - getResolutionProgressPromise() { - return this.resolutionProgressHolder.getPromise(); + return this.resolutionProgress; } /* @@ -187,40 +165,22 @@ export default class WorkdirContext { if (this.destroyed) { return; } - this.destroyed = true; + + this.repository.destroy(); this.subs.dispose(); this.emitter.dispose(); - const observer = this.getChangeObserver(); - const repository = this.getRepository(); - - repository && repository.destroy(); - - if (observer) { - await observer.destroy(); - } + await this.observer.destroy(); } } export class NullWorkdirContext extends WorkdirContext { constructor(directory) { - super(directory, {}); - - this.repositoryHolder.setValue(null); - this.changeObserverHolder.setValue(null); - this.resolutionProgressHolder.setValue(null); - } - - create() { - return Promise.resolve(); + super(directory, {dormant: DORMANT}); } isPresent() { return false; } - - destroy() { - return Promise.resolve(); - } } diff --git a/test/models/workdir-context.test.js b/test/models/workdir-context.test.js index 34546d689d..070593f6ce 100644 --- a/test/models/workdir-context.test.js +++ b/test/models/workdir-context.test.js @@ -1,28 +1,43 @@ import {CompositeDisposable, Disposable} from 'event-kit'; -import {cloneRepository} from '../helpers'; +import {cloneRepository, sha} from '../helpers'; import WorkdirContext from '../../lib/models/workdir-context'; import Repository from '../../lib/models/repository'; describe('WorkdirContext', function() { - let context, workingDirectory, subs; - let mockWindow, mockWorkspace, mockPromptCallback; + let context, workingDirectory, subs, options; + let mockPromptCallback; beforeEach(async function() { workingDirectory = await cloneRepository('three-files'); subs = new CompositeDisposable(); - mockWindow = { + const mockWindow = { addEventListener: sinon.spy(), removeEventListener: sinon.spy(), }; - mockWorkspace = { + const mockWorkspace = { observeTextEditors: sinon.stub().returns(new Disposable()), }; + const mockSavedResolutionProgress = { + revision: await sha(workingDirectory), + paths: { + 'a.txt': 2, + 'b.txt': 3, + }, + }; + mockPromptCallback = query => 'reply'; + + options = { + window: mockWindow, + workspace: mockWorkspace, + promptCallback: mockPromptCallback, + savedResolutionProgress: mockSavedResolutionProgress, + }; }); afterEach(async function() { @@ -32,115 +47,86 @@ describe('WorkdirContext', function() { describe('without an initial Repository', function() { beforeEach(function() { - context = new WorkdirContext(workingDirectory, { - window: mockWindow, - workspace: mockWorkspace, - promptCallback: mockPromptCallback, - }); + context = new WorkdirContext(workingDirectory, options); }); - it('returns null for each synchronous model', function() { - assert.isNull(context.getRepository()); - assert.isNull(context.getResolutionProgress()); - assert.isNull(context.getChangeObserver()); - }); - - it('populates the repository on create()', async function() { - const repoPromise = context.getRepositoryPromise(); - - await context.create(); - - const repo = context.getRepository(); - assert.isNotNull(repo); - assert.equal(repo.getWorkingDirectoryPath(), workingDirectory); - assert.strictEqual(repo, await repoPromise); + it('returns synchronous models in their initial states', function() { + assert.isTrue(context.getRepository().isLoading()); + assert.isTrue(context.getResolutionProgress().isEmpty()); + assert.isFalse(context.getChangeObserver().isStarted()); }); - it('populates the resolution progress on create()', async function() { - const progressPromise = context.getResolutionProgressPromise(); - - await context.create(); - + it('updates the resolution progress once the repository loads', async function() { const progress = context.getResolutionProgress(); - assert.isNotNull(progress); - assert.strictEqual(progress, await progressPromise); - }); - it('populates the change observer on create()', async function() { - const observerPromise = context.getChangeObserverPromise(); + await context.getRepositoryStatePromise('Present'); - await context.create(); + assert.isFalse(progress.isEmpty()); + assert.equal(progress.getRemaining('a.txt'), 2); + assert.equal(progress.getRemaining('b.txt'), 3); + }); + it('starts the change observer after the repository loads', async function() { const observer = context.getChangeObserver(); - assert.isNotNull(observer); - assert.strictEqual(observer, await observerPromise); + await context.getObserverStartedPromise(); + assert.isTrue(observer.isStarted()); }); it('configures the repository with a prompt callback', async function() { - await context.create(); - const repo = context.getRepository(); + await repo.getLoadPromise(); + for (const strategy of repo.git.getImplementers()) { assert.strictEqual(strategy.prompt, mockPromptCallback); } }); - it('refreshes the repository on any filesystem change', async function() { - await context.create(); - + it('notifies the repository on any filesystem change', async function() { const repo = context.getRepository(); - sinon.spy(repo, 'refresh'); + await context.getRepositoryStatePromise('Present'); - const observer = context.getChangeObserver(); - observer.emitter.emit('did-change'); + sinon.spy(repo, 'observeFilesystemChange'); + + const payload = {}; + context.getChangeObserver().didChange(payload); - assert.isTrue(repo.refresh.called); + assert.isTrue(repo.observeFilesystemChange.calledWith(payload)); }); - it('emits an event on workdir or head change', async function() { + it('re-emits an event on workdir or head change', async function() { const listener = sinon.spy(); subs.add(context.onDidChangeWorkdirOrHead(listener)); - await context.create(); + await context.getRepositoryStatePromise('Present'); - context.getChangeObserver().emitter.emit('did-change-workdir-or-head'); + context.getChangeObserver().didChangeWorkdirOrHead(); assert.isTrue(listener.called); }); - it('can be destroyed before create()', async function() { - await context.destroy(); - assert.isTrue(context.isDestroyed()); - - await context.create(); - assert.isTrue(context.isDestroyed()); - }); - it('destroys the repository on destroy()', async function() { - await context.create(); + await context.getRepositoryStatePromise('Present'); const repo = context.getRepository(); - sinon.spy(repo, 'destroy'); await context.destroy(); - assert.isTrue(repo.destroy.called); + assert.isTrue(repo.isDestroyed()); }); it('stops the change observer on destroy()', async function() { - await context.create(); const observer = context.getChangeObserver(); await observer.start(); - sinon.spy(observer, 'destroy'); - await context.destroy(); - assert.isTrue(observer.destroy.called); + assert.isFalse(observer.isStarted()); }); it('can be destroyed twice', async function() { - await context.create(); assert.isFalse(context.isDestroyed()); await context.destroy(); assert.isTrue(context.isDestroyed()); + + await context.destroy(); + assert.isTrue(context.isDestroyed()); }); }); @@ -148,37 +134,35 @@ describe('WorkdirContext', function() { let repository; beforeEach(async function() { - repository = await Repository.open(workingDirectory); - context = new WorkdirContext(workingDirectory, { - repository, - window: mockWindow, - workspace: mockWorkspace, - promptCallback: mockPromptCallback, - }); + repository = new Repository(workingDirectory); + await repository.getLoadPromise(); + sinon.spy(repository, 'getLastCommit'); + options.repository = repository; + + context = new WorkdirContext(workingDirectory, options); }); - it('returns the repository synchrounsly', function() { + it('returns the repository synchronously', function() { assert.strictEqual(repository, context.getRepository()); }); - it('populates the resolution progress on create()', async function() { - const progressPromise = context.getResolutionProgressPromise(); + it('begins populating the resolution progress immediately', async function() { + assert.isTrue(repository.getLastCommit.called); - await context.create(); + await context.getRepositoryStatePromise('Present'); const progress = context.getResolutionProgress(); - assert.isNotNull(progress); - assert.strictEqual(progress, await progressPromise); + assert.isFalse(progress.isEmpty()); + assert.equal(progress.getRemaining('a.txt'), 2); + assert.equal(progress.getRemaining('b.txt'), 3); }); - it('populates the change observer on create()', async function() { - const observerPromise = context.getChangeObserverPromise(); - - await context.create(); - + it('starts the change observer automatically', async function() { const observer = context.getChangeObserver(); - assert.isNotNull(observer); - assert.strictEqual(observer, await observerPromise); + assert.isFalse(observer.isStarted()); + + await context.getObserverStartedPromise(); + assert.isTrue(observer.isStarted()); }); }); }); From ef8417691620d3718e5910c665ef2d45a77ec0d5 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 11:48:29 -0400 Subject: [PATCH 082/161] Manage WorkdirContext lifecycle correctly within a pool --- lib/models/workdir-context-pool.js | 32 +++++++----------- test/models/workdir-context-pool.test.js | 42 +++++++++++++----------- 2 files changed, 33 insertions(+), 41 deletions(-) diff --git a/lib/models/workdir-context-pool.js b/lib/models/workdir-context-pool.js index 23e1cf40b2..c513764250 100644 --- a/lib/models/workdir-context-pool.js +++ b/lib/models/workdir-context-pool.js @@ -48,24 +48,18 @@ export default class WorkdirContextPool { const emit = () => this.emitter.emit(emitEventName, context); disposable.add(context[subMethod](emit)); }; + + forwardEvent('onDidStartObserver', 'did-start-observer'); forwardEvent('onDidChangeWorkdirOrHead', 'did-change-workdir-or-head'); + forwardEvent('onDidChangeRepositoryState', 'did-change-repository-state'); forwardEvent('onDidUpdateRepository', 'did-update-repository'); forwardEvent('onDidDestroyRepository', 'did-destroy-repository'); - const forwardPromise = (subMethod, emitEventName) => { - context[subMethod]().then(() => this.emitter.emit(emitEventName, context)); - }; - forwardPromise('getRepositoryPromise', 'did-load-repository'); - forwardPromise('getResolutionProgressPromise', 'did-load-resolution-progress'); - forwardPromise('getChangeObserverPromise', 'did-load-change-observer'); - disposable.add(this.onDidRemoveContext(removed => { if (removed === context) { disposable.dispose(); } })); - - context.create(); } replace(directory, options = {}) { @@ -101,20 +95,20 @@ export default class WorkdirContextPool { } } - onDidRemoveContext(callback) { - return this.emitter.on('did-remove-context', callback); + onDidStartObserver(callback) { + return this.emitter.on('did-start-observer', callback); } - onDidLoadRepository(callback) { - return this.emitter.on('did-load-repository', callback); + onDidChangeWorkdirOrHead(callback) { + return this.emitter.on('did-change-workdir-or-head', callback); } - onDidLoadResolutionProgress(callback) { - return this.emitter.on('did-load-resolution-progress', callback); + onDidRemoveContext(callback) { + return this.emitter.on('did-remove-context', callback); } - onDidLoadChangeObserver(callback) { - return this.emitter.on('did-load-change-observer', callback); + onDidChangeRepositoryState(callback) { + return this.emitter.on('did-change-repository-state', callback); } onDidUpdateRepository(callback) { @@ -125,10 +119,6 @@ export default class WorkdirContextPool { return this.emitter.on('did-destroy-repository', callback); } - onDidChangeWorkdirOrHead(callback) { - return this.emitter.on('did-change-workdir-or-head', callback); - } - clear() { this.withResidentContexts(workdir => this.remove(workdir)); } diff --git a/test/models/workdir-context-pool.test.js b/test/models/workdir-context-pool.test.js index 38d926127e..25ca8a4552 100644 --- a/test/models/workdir-context-pool.test.js +++ b/test/models/workdir-context-pool.test.js @@ -1,6 +1,6 @@ import temp from 'temp'; -import {cloneRepository, buildRepository} from '../helpers'; +import {cloneRepository, buildRepository, sha} from '../helpers'; import Repository from '../../lib/models/repository'; import WorkdirContextPool from '../../lib/models/workdir-context-pool'; @@ -42,8 +42,8 @@ describe('WorkdirContextPool', function() { assert.isTrue(pool.getContext(workingDirectory).isPresent()); }); - it('optionally provides a preinitialized repository', async function() { - const existingRepo = await Repository.open(workingDirectory); + it('optionally provides a preinitialized repository', function() { + const existingRepo = new Repository(workingDirectory); pool.add(workingDirectory, {repository: existingRepo}); @@ -65,24 +65,27 @@ describe('WorkdirContextPool', function() { it('begins but does not await the asynchronous initialization process', async function() { pool.add(workingDirectory); const context = pool.getContext(workingDirectory); - assert.isNull(context.getRepository()); + assert.isTrue(context.getRepository().isLoading()); + assert.isTrue(context.getResolutionProgress().isEmpty()); + assert.isFalse(context.getChangeObserver().isStarted()); - const repo = await context.getRepositoryPromise(); - assert.isNotNull(repo); + await context.getRepositoryStatePromise('Present'); + assert.isTrue(context.getRepository().isPresent()); }); it('passes appropriate serialized state to the resolution progress', async function() { const resolutionProgressByPath = { [workingDirectory]: { - revision: '66d11860af6d28eb38349ef83de475597cb0e8b4', + revision: await sha(workingDirectory), paths: {'a.txt': 12}, }, }; pool.add(workingDirectory, {resolutionProgressByPath}); + const context = pool.getContext(workingDirectory); + await context.getRepositoryStatePromise('Present'); - const resolutionProgress = await pool.getContext(workingDirectory).getResolutionProgressPromise(); - assert.isNotNull(resolutionProgress); + const resolutionProgress = context.getResolutionProgress(); assert.equal(resolutionProgress.getRemaining('a.txt'), 12); }); }); @@ -105,13 +108,14 @@ describe('WorkdirContextPool', function() { pool.add(directory); assert.equal(pool.size(), 1); - assert.isTrue(pool.getContext(directory).isPresent()); - const original = await pool.getContext(directory).getRepositoryPromise(); + const context = pool.getContext(directory); + assert.isTrue(context.isPresent()); + const original = context.getRepository(); pool.replace(directory); assert.equal(pool.size(), 1); assert.isTrue(pool.getContext(directory).isPresent()); - const replaced = await pool.getContext(directory).getRepositoryPromise(); + const replaced = pool.getContext(directory).getRepository(); assert.notStrictEqual(original, replaced); }); @@ -122,14 +126,14 @@ describe('WorkdirContextPool', function() { pool.add(directory); assert.equal(pool.size(), 1); assert.isTrue(pool.getContext(directory).isPresent()); - const original = await pool.getContext(directory).getRepositoryPromise(); + const original = pool.getContext(directory).getRepository(); const replacement = await buildRepository(directory); pool.replace(directory, {repository: replacement}); assert.equal(pool.size(), 1); assert.isTrue(pool.getContext(directory).isPresent()); - const replaced = await pool.getContext(directory).getRepositoryPromise(); + const replaced = pool.getContext(directory).getRepository(); assert.notStrictEqual(original, replaced); assert.strictEqual(replacement, replaced); @@ -159,8 +163,8 @@ describe('WorkdirContextPool', function() { assert.equal(pool.size(), 1); }); - it('begins but does not await the termination process', async function() { - const repo = await existingContext.getRepositoryPromise(); + it('begins but does not await the termination process', function() { + const repo = existingContext.getRepository(); sinon.spy(repo, 'destroy'); assert.isFalse(existingContext.isDestroyed()); @@ -199,8 +203,6 @@ describe('WorkdirContextPool', function() { assert.isTrue(context0.isDestroyed()); assert.isFalse(context1.isDestroyed()); }); - - it('passes appropriate serialized state to any created resolution progresses'); }); describe('getContext', function() { @@ -211,11 +213,11 @@ describe('WorkdirContextPool', function() { pool.add(dir); }); - it('returns a context by directory', async function() { + it('returns a context by directory', function() { const context = pool.getContext(dir); assert.isTrue(context.isPresent()); - const repo = await context.getRepositoryPromise(); + const repo = context.getRepository(); assert.strictEqual(dir, repo.getWorkingDirectoryPath()); }); From d428db30b85022dead5865b832f7183cd6bcb57b Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Fri, 14 Apr 2017 13:09:22 -0400 Subject: [PATCH 083/161] Introduce an "Absent" state when no working directory is available --- lib/models/repository-states/absent.js | 22 +++++++++++++++++ lib/models/repository-states/index.js | 1 + lib/models/repository-states/state.js | 5 ++++ lib/models/repository.js | 33 +++++++++++++++++--------- test/models/repository.test.js | 19 +++++++++++++++ 5 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 lib/models/repository-states/absent.js diff --git a/lib/models/repository-states/absent.js b/lib/models/repository-states/absent.js new file mode 100644 index 0000000000..7f21806f4c --- /dev/null +++ b/lib/models/repository-states/absent.js @@ -0,0 +1,22 @@ +import State from './state'; +import Empty from './empty'; + +export default class Absent extends Empty { + isAbsent() { + return true; + } + + init() { + return disallowed('init'); + } + + clone() { + return disallowed('clone'); + } +} + +State.register(Absent); + +function disallowed(actionName) { + return Promise.reject(new Error(`${actionName} cannot be performed on an absent repository`)); +} diff --git a/lib/models/repository-states/index.js b/lib/models/repository-states/index.js index e57e503eac..954b020473 100644 --- a/lib/models/repository-states/index.js +++ b/lib/models/repository-states/index.js @@ -1,6 +1,7 @@ export {expectedDelegates} from './state'; export {default as InitialState} from './loading'; +export {default as AbsentState} from './absent'; // Load and register remaining states import './empty'; diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index 6ea7313cbb..19250590ca 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -48,6 +48,11 @@ export default class State { return false; } + @shouldDelegate + isAbsent() { + return false; + } + @shouldDelegate isPresent() { return false; diff --git a/lib/models/repository.js b/lib/models/repository.js index e3dfba0e99..e5f7fca67d 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -5,10 +5,12 @@ import {Emitter} from 'event-kit'; import CompositeGitStrategy from '../composite-git-strategy'; import {readFile} from '../helpers'; import Remote, {nullRemote} from './remote'; -import {InitialState} from './repository-states'; +import {InitialState, AbsentState} from './repository-states'; const MERGE_MARKER_REGEX = /^(>|<){7} \S+$/m; +const ABSENT_TOKEN = {}; + export class AbortMergeError extends Error { constructor(code, filePath) { super(); @@ -35,18 +37,27 @@ export default class Repository { this.emitter = new Emitter(); - this.loadPromise = new Promise(resolve => { - const sub = this.onDidChangeState(() => { - if (!this.isLoading()) { - resolve(); - sub.dispose(); - } else if (this.isDestroyed()) { - sub.dispose(); - } + if (workingDirectoryPath !== ABSENT_TOKEN) { + this.loadPromise = new Promise(resolve => { + const sub = this.onDidChangeState(() => { + if (!this.isLoading()) { + resolve(); + sub.dispose(); + } else if (this.isDestroyed()) { + sub.dispose(); + } + }); }); - }); - this.transitionTo(InitialState); + this.transitionTo(InitialState); + } else { + this.loadPromise = Promise.reject(new Error('An absent repository will never load')); + this.transitionTo(AbsentState); + } + } + + static absent() { + return new Repository(ABSENT_TOKEN); } // State management ////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/test/models/repository.test.js b/test/models/repository.test.js index f765303896..cbbe505985 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -27,6 +27,25 @@ describe('Repository', function() { assert.lengthOf(missing, 0); }); + describe('initial states', function() { + let repository; + + afterEach(async function() { + repository && await repository.destroy(); + }); + + it('begins in the Loading state with a working directory', async function() { + const workdir = await cloneRepository('three-files'); + repository = new Repository(workdir); + assert.isTrue(repository.isInState('Loading')); + }); + + it('begins in the Absent state with .absent()', function() { + repository = Repository.absent(); + assert.isTrue(repository.isInState('Absent')); + }); + }); + describe('init', function() { it('creates a repository in the given dir and returns the repository', async function() { const soonToBeRepositoryPath = fs.realpathSync(temp.mkdirSync()); From 30aa5aec6455408d0f4b1b69a91c7900575cee26 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:16:12 -0400 Subject: [PATCH 084/161] Use nullBranch and nullRemote in defaultProps --- lib/controllers/github-tab-controller.js | 11 +++++---- lib/controllers/remote-pr-controller.js | 2 ++ .../repository-conflict-controller.js | 8 +++---- lib/controllers/status-bar-tile-controller.js | 17 ++++++++----- lib/views/push-pull-menu-view.js | 24 ++++++++++--------- 5 files changed, 35 insertions(+), 27 deletions(-) diff --git a/lib/controllers/github-tab-controller.js b/lib/controllers/github-tab-controller.js index 537b340222..27b0d119f2 100644 --- a/lib/controllers/github-tab-controller.js +++ b/lib/controllers/github-tab-controller.js @@ -5,6 +5,7 @@ import yubikiri from 'yubikiri'; import RemotePrController from './remote-pr-controller'; import GithubLoginModel from '../models/github-login-model'; +import {nullBranch} from '../models/branch'; import ObserveModelDecorator from '../decorators/observe-model'; import {RemotePropType, BranchPropType} from '../prop-types'; @@ -51,14 +52,14 @@ class RemoteSelector extends React.Component { export default class GithubTabController extends React.Component { static propTypes = { repository: PropTypes.object, - remotes: PropTypes.arrayOf(RemotePropType.isRequired), - currentBranch: BranchPropType, + remotes: PropTypes.arrayOf(RemotePropType).isRequired, + currentBranch: BranchPropType.isRequired, selectedRemoteName: PropTypes.string, } static defaultProps = { - remotes: null, - currentBranch: {name: '', isDetached: false}, + remotes: [], + currentBranch: nullBranch, selectedRemoteName: null, } @@ -68,7 +69,7 @@ export default class GithubTabController extends React.Component { } render() { - if (!this.props.repository.isPresent() || this.props.remotes.length === 0) { + if (!this.props.repository.isPresent()) { return null; } diff --git a/lib/controllers/remote-pr-controller.js b/lib/controllers/remote-pr-controller.js index 5c2f64f38a..c2a2fd4acc 100644 --- a/lib/controllers/remote-pr-controller.js +++ b/lib/controllers/remote-pr-controller.js @@ -7,6 +7,7 @@ import {RemotePropType} from '../prop-types'; import ObserveModelDecorator from '../decorators/observe-model'; import GithubLoginView from '../views/github-login-view'; import {UNAUTHENTICATED} from '../models/github-login-model'; +import {nullRemote} from '../models/remote'; import PrInfoController from './pr-info-controller'; @ObserveModelDecorator({ @@ -31,6 +32,7 @@ export default class RemotePrController extends React.Component { static defaultProps = { host: 'https://api.github.com', + remote: nullRemote, token: null, } diff --git a/lib/controllers/repository-conflict-controller.js b/lib/controllers/repository-conflict-controller.js index 758b366fe0..530f8f114f 100644 --- a/lib/controllers/repository-conflict-controller.js +++ b/lib/controllers/repository-conflict-controller.js @@ -25,9 +25,8 @@ export default class RepositoryConflictController extends React.Component { static propTypes = { workspace: PropTypes.object.isRequired, commandRegistry: PropTypes.object.isRequired, - resolutionProgress: PropTypes.object, - repository: PropTypes.object, - workingDirectoryPath: PropTypes.string, + resolutionProgress: PropTypes.object.isRequired, + repository: PropTypes.object.isRequired, mergeConflictPaths: PropTypes.arrayOf(PropTypes.string), isRebasing: PropTypes.bool, refreshResolutionProgress: PropTypes.func, @@ -76,14 +75,13 @@ export default class RepositoryConflictController extends React.Component { getConflictingEditors() { if ( - !this.props.workingDirectoryPath || this.props.mergeConflictPaths.length === 0 || this.state.openEditors.length === 0 ) { return []; } - const commonBasePath = this.props.workingDirectoryPath; + const commonBasePath = this.props.repository.getWorkingDirectoryPath(); const fullMergeConflictPaths = new Set( this.props.mergeConflictPaths.map(relativePath => path.join(commonBasePath, relativePath)), ); diff --git a/lib/controllers/status-bar-tile-controller.js b/lib/controllers/status-bar-tile-controller.js index f41f1a2539..d710400ed7 100644 --- a/lib/controllers/status-bar-tile-controller.js +++ b/lib/controllers/status-bar-tile-controller.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import ObserveModelDecorator from '../decorators/observe-model'; -import {BranchPropType} from '../prop-types'; +import {BranchPropType, RemotePropType} from '../prop-types'; import BranchView from '../views/branch-view'; import BranchMenuView from '../views/branch-menu-view'; import PushPullView from '../views/push-pull-view'; @@ -10,6 +10,8 @@ import PushPullMenuView from '../views/push-pull-menu-view'; import ChangedFilesCountView from '../views/changed-files-count-view'; import Tooltip from '../views/tooltip'; import Commands, {Command} from '../views/commands'; +import {nullBranch} from '../models/branch'; +import {nullRemote} from '../models/remote'; import yubikiri from 'yubikiri'; import {autobind} from 'core-decorators'; @@ -35,7 +37,7 @@ import {autobind} from 'core-decorators'; return changedFiles.size; }), - remoteName: async query => repository.getRemoteForBranch((await query.currentBranch).getName()), + currentRemote: async query => repository.getRemoteForBranch((await query.currentBranch).getName()), aheadCount: async query => repository.getAheadCount((await query.currentBranch).getName()), behindCount: async query => repository.getBehindCount((await query.currentBranch).getName()), }); @@ -50,7 +52,7 @@ export default class StatusBarTileController extends React.Component { repository: PropTypes.object.isRequired, currentBranch: BranchPropType.isRequired, branches: PropTypes.arrayOf(BranchPropType).isRequired, - remoteName: PropTypes.string, + currentRemote: RemotePropType.isRequired, aheadCount: PropTypes.number, behindCount: PropTypes.number, changedFilesCount: PropTypes.number, @@ -58,6 +60,9 @@ export default class StatusBarTileController extends React.Component { } static defaultProps = { + currentBranch: nullBranch, + branches: [], + currentRemote: nullRemote, toggleGitPanel: () => {}, } @@ -75,7 +80,7 @@ export default class StatusBarTileController extends React.Component { const repoProps = { currentBranch: this.props.currentBranch, branches: this.props.branches, - remoteName: this.props.remoteName, + currentRemote: this.props.currentRemote, aheadCount: this.props.aheadCount, behindCount: this.props.behindCount, changedFilesCount: this.props.changedFilesCount, @@ -88,11 +93,11 @@ export default class StatusBarTileController extends React.Component { this.push({force: false, setUpstream: !this.props.remoteName})} + callback={() => this.push({force: false, setUpstream: !this.props.currentRemote.isPresent()})} /> this.push({force: true, setUpstream: !this.props.remoteName})} + callback={() => this.push({force: true, setUpstream: !this.props.currentRemote.isPresent()})} /> @@ -103,7 +105,7 @@ export default class PushPullMenuView extends React.Component { return 'Note: you are not on a branch. Please create one if you wish to push your work anywhere.'; } - if (!this.props.remoteName) { + if (!this.props.currentRemote.isPresent()) { return `Note: No remote detected for branch ${this.props.currentBranch.getName()}. ` + 'Pushing will set up a remote tracking branch on remote repo "origin"'; } @@ -162,7 +164,7 @@ export default class PushPullMenuView extends React.Component { @autobind async push() { await this.attemptGitOperation( - () => this.props.push({force: this.state.force, setUpstream: !this.props.remoteName}), + () => this.props.push({force: this.state.force, setUpstream: !this.props.currentRemote.isPresent()}), description => { if (/rejected[\s\S]*failed to push/.test(description)) { return { From 5190d6386729aa031199f6a697a6d986cd4387ed Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:16:30 -0400 Subject: [PATCH 085/161] Async tempdir helper --- lib/helpers.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/helpers.js b/lib/helpers.js index c43a51943d..09cd14d0d8 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -1,5 +1,6 @@ import path from 'path'; import fs from 'fs-extra'; +import temp from 'temp'; import {ncp} from 'ncp'; export function getPackageRoot() { @@ -149,10 +150,15 @@ export function copyFile(source, target) { }); } -export function getTempDir(prefix) { +export function getTempDir(prefix = '') { return new Promise((resolve, reject) => { - fs.mkdtemp(prefix, (err, folder) => { - if (err) { return reject(err); } else { return resolve(folder); } + temp.mkdir(prefix, (tempError, folder) => { + if (tempError) { + reject(tempError); + return; + } + + fs.realpath(folder, (realError, rpath) => (realError ? reject(realError) : resolve(rpath))); }); }); } From 778183e5700e03b99d86e8045397f65e898911b8 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:16:50 -0400 Subject: [PATCH 086/161] Branch and Remote polish --- lib/models/branch.js | 4 ++-- lib/models/remote.js | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/models/branch.js b/lib/models/branch.js index 3d2aecd3ea..6f6493ad6f 100644 --- a/lib/models/branch.js +++ b/lib/models/branch.js @@ -3,7 +3,7 @@ const DETACHED = {}; export default class Branch { constructor(name, detached = null) { this.name = name; - this.isDetached = detached === DETACHED; + this.detached = detached === DETACHED; } static createDetached(describe) { @@ -15,7 +15,7 @@ export default class Branch { } isDetached() { - return this.isDetached; + return this.detached; } isPresent() { diff --git a/lib/models/remote.js b/lib/models/remote.js index b802449e88..e272d62767 100644 --- a/lib/models/remote.js +++ b/lib/models/remote.js @@ -39,6 +39,14 @@ export default class Remote { } function githubInfoFromRemote(remoteUrl) { + if (!remoteUrl) { + return { + isGithubRepo: false, + owner: null, + repo: null, + }; + } + // proto login domain owner repo const regex = /(?:.+:\/\/)?(?:.+@)?github\.com[:/]([^/]+)\/(.+)/; const match = remoteUrl.match(regex); @@ -62,6 +70,22 @@ export const nullRemote = { return ''; }, + getUrl() { + return ''; + }, + + isGithubRepo() { + return false; + }, + + getOwner() { + return null; + }, + + getRepo() { + return null; + }, + getNameOr(fallback) { return fallback; }, From 5794042ee2ea0155dbe2cb3809ff35fa3b93cec3 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:17:03 -0400 Subject: [PATCH 087/161] Return the `transitionTo` result promise --- lib/models/repository-states/loading.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/models/repository-states/loading.js b/lib/models/repository-states/loading.js index 4214c6e96d..1b9ea13015 100644 --- a/lib/models/repository-states/loading.js +++ b/lib/models/repository-states/loading.js @@ -4,9 +4,9 @@ export default class Loading extends State { async start() { if (await this.git().isGitRepository()) { const history = await this.loadHistoryPayload(); - this.transitionTo('Present', history); + return this.transitionTo('Present', history); } else { - this.transitionTo('Empty'); + return this.transitionTo('Empty'); } } From 1b3364ecbce483e70f64d67d52a89d0cba56abf2 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:17:17 -0400 Subject: [PATCH 088/161] Clear the cache on operation failure too --- lib/models/repository-states/present.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index b93ab659cc..1301d0a153 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -53,6 +53,7 @@ function invalidate() { return result; }, err => { + cache.clear(); return Promise.reject(err); }, ); From e75eb354c03c082ad0599e8de68d2970b3d40451 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:17:31 -0400 Subject: [PATCH 089/161] Return Branch array from getBranches() --- lib/models/repository-states/present.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index 1301d0a153..be44c1b0a7 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -337,8 +337,9 @@ export default class Present extends State { // Branches getBranches() { - return this.cache.getOrSet('branches', () => { - return this.git().getBranches(); + return this.cache.getOrSet('branches', async () => { + const branchNames = await this.git().getBranches(); + return branchNames.map(branchName => new Branch(branchName)); }); } From fb27202b20c240cc155aaf376beb8a3b43264ed5 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:17:43 -0400 Subject: [PATCH 090/161] Delegate `.refresh()` back to Repository --- lib/models/repository-states/state.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index 19250590ca..9330ae0416 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -88,7 +88,7 @@ export default class State { @shouldDelegate observeFilesystemChange() { - this.refresh(); + this.repository.refresh(); } // Git operations //////////////////////////////////////////////////////////////////////////////////////////////////// From 724285d09486295da6ca994798e89eeca0f42cab Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:18:05 -0400 Subject: [PATCH 091/161] Make nullWorkdirContext a singleton --- lib/models/workdir-context-pool.js | 8 +++--- lib/models/workdir-context.js | 41 +++++++++++++++++------------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/lib/models/workdir-context-pool.js b/lib/models/workdir-context-pool.js index c513764250..c5db10fac9 100644 --- a/lib/models/workdir-context-pool.js +++ b/lib/models/workdir-context-pool.js @@ -1,7 +1,7 @@ import compareSets from 'compare-sets'; import {Emitter, CompositeDisposable} from 'event-kit'; -import WorkdirContext, {NullWorkdirContext} from './workdir-context'; +import WorkdirContext, {nullWorkdirContext} from './workdir-context'; /** * Manage a WorkdirContext for each open directory. @@ -26,12 +26,12 @@ export default class WorkdirContextPool { * Access the context mapped to a known directory. */ getContext(directory) { - return this.contexts.get(directory) || new NullWorkdirContext(directory); + return this.contexts.get(directory) || nullWorkdirContext; } add(directory, options = {}) { if (this.contexts.has(directory)) { - return; + return this.getContext(directory); } let savedResolutionProgress = {}; @@ -60,6 +60,8 @@ export default class WorkdirContextPool { disposable.dispose(); } })); + + return context; } replace(directory, options = {}) { diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index 737daa4ded..379d95e2e0 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -6,7 +6,7 @@ import ResolutionProgress from './conflicts/resolution-progress'; import FileSystemChangeObserver from './file-system-change-observer'; import WorkspaceChangeObserver from './workspace-change-observer'; -const DORMANT = {}; +const ABSENT = {}; /* * Bundle of model objects associated with a git working directory. @@ -20,22 +20,27 @@ export default class WorkdirContext { /* * Available options: - * - `options.repository`: Pre-initialized Repository instance if one is available. * - `options.savedResolutionProgress`: Serialized state used to initialize the ResolutionProgress. * - `options.window`: Browser window global, used on Linux by the WorkspaceChangeObserver. * - `options.workspace`: Atom's workspace singleton, used on Linux by the WorkspaceChangeObserver. * - `options.promptCallback`: Callback used to collect information interactively through Atom. */ constructor(directory, options = {}) { - this.directory = directory; - const {repository, window: theWindow, workspace, promptCallback} = options; + if (directory !== ABSENT) { + this.directory = directory; + this.repository = new Repository(directory); + } else { + this.directory = null; + this.repository = Repository.absent(); + } + + const {window: theWindow, workspace, promptCallback} = options; this.savedResolutionProgress = options.savedResolutionProgress || {}; this.destroyed = false; this.emitter = new Emitter(); this.subs = new CompositeDisposable(); - this.repository = repository || new Repository(directory); this.observer = process.platform === 'linux' ? new WorkspaceChangeObserver(theWindow, workspace, this.repository) : new FileSystemChangeObserver(this.repository); @@ -45,17 +50,13 @@ export default class WorkdirContext { this.repository.setPromptCallback(promptCallback); } - if (options.dormant !== DORMANT) { - // Wire up event forwarding among models - this.subs.add(this.repository.onDidChangeState(this.repositoryChangedState)); - this.subs.add(this.observer.onDidChange(payload => this.repository.observeFilesystemChange(payload))); - this.subs.add(this.observer.onDidChangeWorkdirOrHead(() => this.emitter.emit('did-change-workdir-or-head'))); + // Wire up event forwarding among models + this.subs.add(this.repository.onDidChangeState(this.repositoryChangedState)); + this.subs.add(this.observer.onDidChange(payload => this.repository.observeFilesystemChange(payload))); + this.subs.add(this.observer.onDidChangeWorkdirOrHead(() => this.emitter.emit('did-change-workdir-or-head'))); - // If a pre-loaded Repository was provided, broadcast an initial state change event. - if (this.repository) { - this.repositoryChangedState({from: null, to: this.repository.state}); - } - } + // If a pre-loaded Repository was provided, broadcast an initial state change event. + this.repositoryChangedState({from: null, to: this.repository.state}); } /** @@ -78,6 +79,8 @@ export default class WorkdirContext { const head = await this.repository.getLastCommit(); this.resolutionProgress.load(head.getSha(), this.savedResolutionProgress); } else if (this.repository.isDestroyed()) { + this.emitter.emit('did-destroy-repository'); + this.observer.destroy(); } @@ -175,12 +178,14 @@ export default class WorkdirContext { } } -export class NullWorkdirContext extends WorkdirContext { - constructor(directory) { - super(directory, {dormant: DORMANT}); +class NullWorkdirContext extends WorkdirContext { + constructor() { + super(ABSENT); } isPresent() { return false; } } + +export const nullWorkdirContext = new NullWorkdirContext(); From a73451918438914f53cdafa547ce9572e8fc1dff Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:18:20 -0400 Subject: [PATCH 092/161] Handle absent Repositories --- lib/models/repository.js | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/lib/models/repository.js b/lib/models/repository.js index e5f7fca67d..1ad962de08 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -9,7 +9,7 @@ import {InitialState, AbsentState} from './repository-states'; const MERGE_MARKER_REGEX = /^(>|<){7} \S+$/m; -const ABSENT_TOKEN = {}; +const ABSENT = {}; export class AbortMergeError extends Error { constructor(code, filePath) { @@ -37,27 +37,22 @@ export default class Repository { this.emitter = new Emitter(); - if (workingDirectoryPath !== ABSENT_TOKEN) { - this.loadPromise = new Promise(resolve => { - const sub = this.onDidChangeState(() => { - if (!this.isLoading()) { - resolve(); - sub.dispose(); - } else if (this.isDestroyed()) { - sub.dispose(); - } - }); + this.loadPromise = new Promise(resolve => { + const sub = this.onDidChangeState(() => { + if (!this.isLoading()) { + resolve(); + sub.dispose(); + } else if (this.isDestroyed()) { + sub.dispose(); + } }); + }); - this.transitionTo(InitialState); - } else { - this.loadPromise = Promise.reject(new Error('An absent repository will never load')); - this.transitionTo(AbsentState); - } + this.transitionTo(workingDirectoryPath !== ABSENT ? InitialState : AbsentState); } static absent() { - return new Repository(ABSENT_TOKEN); + return new Repository(ABSENT); } // State management ////////////////////////////////////////////////////////////////////////////////////////////////// @@ -76,7 +71,7 @@ export default class Repository { } getLoadPromise() { - return this.loadPromise; + return this.isAbsent() ? this.loadPromise : Promise.reject(new Error('An absent repository will never load')); } /* @@ -176,6 +171,7 @@ export default class Repository { const delegates = [ 'isLoading', 'isEmpty', + 'isAbsent', 'isPresent', 'isDestroyed', From d575d686b1ef78bbf06b9baee8582fe5ec271a0d Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:49:12 -0400 Subject: [PATCH 093/161] Simplify WorkdirContext tests for synchronous models --- test/models/workdir-context.test.js | 161 ++++++++++------------------ 1 file changed, 59 insertions(+), 102 deletions(-) diff --git a/test/models/workdir-context.test.js b/test/models/workdir-context.test.js index 070593f6ce..07748c599d 100644 --- a/test/models/workdir-context.test.js +++ b/test/models/workdir-context.test.js @@ -3,10 +3,9 @@ import {CompositeDisposable, Disposable} from 'event-kit'; import {cloneRepository, sha} from '../helpers'; import WorkdirContext from '../../lib/models/workdir-context'; -import Repository from '../../lib/models/repository'; describe('WorkdirContext', function() { - let context, workingDirectory, subs, options; + let context, workingDirectory, subs; let mockPromptCallback; beforeEach(async function() { @@ -32,12 +31,12 @@ describe('WorkdirContext', function() { mockPromptCallback = query => 'reply'; - options = { + context = new WorkdirContext(workingDirectory, { window: mockWindow, workspace: mockWorkspace, promptCallback: mockPromptCallback, savedResolutionProgress: mockSavedResolutionProgress, - }; + }); }); afterEach(async function() { @@ -45,124 +44,82 @@ describe('WorkdirContext', function() { subs.dispose(); }); - describe('without an initial Repository', function() { - beforeEach(function() { - context = new WorkdirContext(workingDirectory, options); - }); - - it('returns synchronous models in their initial states', function() { - assert.isTrue(context.getRepository().isLoading()); - assert.isTrue(context.getResolutionProgress().isEmpty()); - assert.isFalse(context.getChangeObserver().isStarted()); - }); - - it('updates the resolution progress once the repository loads', async function() { - const progress = context.getResolutionProgress(); - - await context.getRepositoryStatePromise('Present'); - - assert.isFalse(progress.isEmpty()); - assert.equal(progress.getRemaining('a.txt'), 2); - assert.equal(progress.getRemaining('b.txt'), 3); - }); - - it('starts the change observer after the repository loads', async function() { - const observer = context.getChangeObserver(); - await context.getObserverStartedPromise(); - assert.isTrue(observer.isStarted()); - }); - - it('configures the repository with a prompt callback', async function() { - const repo = context.getRepository(); - await repo.getLoadPromise(); - - for (const strategy of repo.git.getImplementers()) { - assert.strictEqual(strategy.prompt, mockPromptCallback); - } - }); - - it('notifies the repository on any filesystem change', async function() { - const repo = context.getRepository(); - await context.getRepositoryStatePromise('Present'); + it('returns synchronous models in their initial states', function() { + assert.isTrue(context.getRepository().isLoading()); + assert.isTrue(context.getResolutionProgress().isEmpty()); + assert.isFalse(context.getChangeObserver().isStarted()); + }); - sinon.spy(repo, 'observeFilesystemChange'); + it('updates the resolution progress once the repository loads', async function() { + const progress = context.getResolutionProgress(); - const payload = {}; - context.getChangeObserver().didChange(payload); + await context.getRepositoryStatePromise('Present'); - assert.isTrue(repo.observeFilesystemChange.calledWith(payload)); - }); + assert.isFalse(progress.isEmpty()); + assert.equal(progress.getRemaining('a.txt'), 2); + assert.equal(progress.getRemaining('b.txt'), 3); + }); - it('re-emits an event on workdir or head change', async function() { - const listener = sinon.spy(); - subs.add(context.onDidChangeWorkdirOrHead(listener)); + it('starts the change observer after the repository loads', async function() { + const observer = context.getChangeObserver(); + await context.getObserverStartedPromise(); + assert.isTrue(observer.isStarted()); + }); - await context.getRepositoryStatePromise('Present'); + it('configures the repository with a prompt callback', async function() { + const repo = context.getRepository(); + await repo.getLoadPromise(); - context.getChangeObserver().didChangeWorkdirOrHead(); - assert.isTrue(listener.called); - }); + for (const strategy of repo.git.getImplementers()) { + assert.strictEqual(strategy.prompt, mockPromptCallback); + } + }); - it('destroys the repository on destroy()', async function() { - await context.getRepositoryStatePromise('Present'); - const repo = context.getRepository(); + it('notifies the repository on any filesystem change', async function() { + const repo = context.getRepository(); + await context.getRepositoryStatePromise('Present'); - await context.destroy(); - assert.isTrue(repo.isDestroyed()); - }); + sinon.spy(repo, 'observeFilesystemChange'); - it('stops the change observer on destroy()', async function() { - const observer = context.getChangeObserver(); - await observer.start(); + const payload = {}; + context.getChangeObserver().didChange(payload); - await context.destroy(); - assert.isFalse(observer.isStarted()); - }); + assert.isTrue(repo.observeFilesystemChange.calledWith(payload)); + }); - it('can be destroyed twice', async function() { - assert.isFalse(context.isDestroyed()); + it('re-emits an event on workdir or head change', async function() { + const listener = sinon.spy(); + subs.add(context.onDidChangeWorkdirOrHead(listener)); - await context.destroy(); - assert.isTrue(context.isDestroyed()); + await context.getRepositoryStatePromise('Present'); - await context.destroy(); - assert.isTrue(context.isDestroyed()); - }); + context.getChangeObserver().didChangeWorkdirOrHead(); + assert.isTrue(listener.called); }); - describe('with an initial Repository', function() { - let repository; + it('destroys the repository on destroy()', async function() { + await context.getRepositoryStatePromise('Present'); + const repo = context.getRepository(); - beforeEach(async function() { - repository = new Repository(workingDirectory); - await repository.getLoadPromise(); - sinon.spy(repository, 'getLastCommit'); - options.repository = repository; - - context = new WorkdirContext(workingDirectory, options); - }); - - it('returns the repository synchronously', function() { - assert.strictEqual(repository, context.getRepository()); - }); + await context.destroy(); + assert.isTrue(repo.isDestroyed()); + }); - it('begins populating the resolution progress immediately', async function() { - assert.isTrue(repository.getLastCommit.called); + it('stops the change observer on destroy()', async function() { + const observer = context.getChangeObserver(); + await observer.start(); - await context.getRepositoryStatePromise('Present'); + await context.destroy(); + assert.isFalse(observer.isStarted()); + }); - const progress = context.getResolutionProgress(); - assert.isFalse(progress.isEmpty()); - assert.equal(progress.getRemaining('a.txt'), 2); - assert.equal(progress.getRemaining('b.txt'), 3); - }); + it('can be destroyed twice', async function() { + assert.isFalse(context.isDestroyed()); - it('starts the change observer automatically', async function() { - const observer = context.getChangeObserver(); - assert.isFalse(observer.isStarted()); + await context.destroy(); + assert.isTrue(context.isDestroyed()); - await context.getObserverStartedPromise(); - assert.isTrue(observer.isStarted()); - }); + await context.destroy(); + assert.isTrue(context.isDestroyed()); }); }); From dd88f6f2f99a38d1c648bb8c8234d9688b21eaac Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:49:50 -0400 Subject: [PATCH 094/161] Use the nullWorkdirContext singleton --- lib/github-package.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 0071bfdd86..abf9fab748 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -7,7 +7,7 @@ import ReactDom from 'react-dom'; import {autobind} from 'core-decorators'; import WorkdirCache from './models/workdir-cache'; -import {NullWorkdirContext} from './models/workdir-context'; +import {nullWorkdirContext} from './models/workdir-context'; import WorkdirContextPool from './models/workdir-context-pool'; import Repository from './models/repository'; import StyleCalculator from './models/style-calculator'; @@ -36,7 +36,7 @@ export default class GithubPackage { this.confirm = confirm; this.activeContextQueue = new AsyncQueue(); - this.activeContext = new NullWorkdirContext(null); + this.activeContext = nullWorkdirContext; this.workdirCache = new WorkdirCache(); this.contextPool = new WorkdirContextPool({ window, @@ -57,7 +57,7 @@ export default class GithubPackage { }), this.contextPool.onDidDestroyRepository(context => { if (context === this.activeContext) { - this.setActiveContext(new NullWorkdirContext(context.getWorkingDirectory())); + this.setActiveContext(nullWorkdirContext); } }), ); From ea1000b89fc0a728c2c98abf8e58ee2597e5d5b1 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:50:05 -0400 Subject: [PATCH 095/161] No need to .rerender() on repository load --- lib/github-package.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/github-package.js b/lib/github-package.js index abf9fab748..c859dd5dd4 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -51,7 +51,6 @@ export default class GithubPackage { this.contextPool.onDidChangeWorkdirOrHead(context => { this.refreshAtomGitRepository(context.getWorkingDirectory()); }), - this.contextPool.onDidLoadRepository(context => context === this.activeContext && this.rerender()), this.contextPool.onDidUpdateRepository(context => { this.switchboard.didUpdateRepository(context.getRepository()); }), From b7937fdc818a666f511d38651d6495950b0d75e9 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:50:16 -0400 Subject: [PATCH 096/161] :fire: unused Disposable cleanup --- lib/github-package.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index c859dd5dd4..dbe16359da 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -194,12 +194,7 @@ export default class GithubPackage { async deactivate() { this.subscriptions.dispose(); - - this.destroyedRepositorySubscription && this.destroyedRepositorySubscription.dispose(); - this.changedRepositorySubscription && this.changedRepositorySubscription.dispose(); - this.contextPool.clear(); - await yardstick.flush(); } From 51320c7796df08a67f8c7b7351ee5094900a7f38 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:50:42 -0400 Subject: [PATCH 097/161] Clone and init work with empty Repositories --- lib/github-package.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index dbe16359da..c1751a94bf 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -216,8 +216,9 @@ export default class GithubPackage { if (!projectPath) { throw new Error('Must specify a project path to create a repository for'); } - const repository = await Repository.init(projectPath); - this.contextPool.replace(projectPath, {repository}); + + const repository = this.contextPool.add(projectPath).getRepository(); + await repository.init(); this.workdirCache.invalidate(projectPath); await this.scheduleActiveContextUpdate(); @@ -225,8 +226,10 @@ export default class GithubPackage { @autobind async cloneRepositoryForProjectPath(remoteUrl, projectPath) { - const repository = await Repository.clone(remoteUrl, projectPath); - this.contextPool.replace(projectPath, {repository}); + const context = this.contextPool.getContext(projectPath); + const repository = context.isPresent() ? context.getRepository() : new Repository(projectPath); + + await repository.clone(remoteUrl); this.workdirCache.invalidate(projectPath); this.project.addPath(projectPath); From 818ea8314353c263dc63e300b5b6e7b9f166ed2f Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:51:04 -0400 Subject: [PATCH 098/161] Always include project path contexts, even if they are empty --- lib/github-package.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index c1751a94bf..8759dc9a68 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -272,17 +272,23 @@ export default class GithubPackage { * projects and pane items. */ async getNextContext(savedState) { - const candidates = new Set(this.project.getPaths()); + const workdirs = new Set( + await Promise.all( + this.project.getPaths().map(async projectPath => { + const workdir = await this.workdirCache.find(projectPath); + return workdir || projectPath; + }), + ), + ); const activeItemPath = pathForPaneItem(this.workspace.getActivePaneItem()); - if (activeItemPath) { - candidates.add(activeItemPath); + if (activeItemPath && !this.project.contains(activeItemPath)) { + const activeItemWorkdir = await this.workdirCache.find(activeItemPath); + if (activeItemWorkdir) { + workdirs.add(activeItemWorkdir); + } } - const workdirs = await Promise.all( - Array.from(candidates, candidate => this.workdirCache.find(candidate)), - ); - this.contextPool.set(workdirs, savedState); if (activeItemPath) { From 9bcbc98d13dc69d86f093644a73487ef0c6dfd47 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:51:16 -0400 Subject: [PATCH 099/161] Switchboard accessor for clarify in tests --- lib/github-package.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/github-package.js b/lib/github-package.js index 8759dc9a68..ff76529c2c 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -231,6 +231,7 @@ export default class GithubPackage { await repository.clone(remoteUrl); this.workdirCache.invalidate(projectPath); + this.project.addPath(projectPath); await this.scheduleActiveContextUpdate(); @@ -252,6 +253,10 @@ export default class GithubPackage { return this.contextPool; } + getSwitchboard() { + return this.switchboard; + } + @autobind async scheduleActiveContextUpdate(savedState = {}) { this.switchboard.didScheduleActiveContextUpdate(); From 3327006cd66f1288e026b66392a8dd7963eb3f8d Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:52:05 -0400 Subject: [PATCH 100/161] Await the right things in GithubPackage tests --- test/github-package.test.js | 166 +++++++++++++++++++++--------------- 1 file changed, 97 insertions(+), 69 deletions(-) diff --git a/test/github-package.test.js b/test/github-package.test.js index d52d5a46e2..097371aa1a 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -4,7 +4,7 @@ import temp from 'temp'; import until from 'test-until'; import {cloneRepository} from './helpers'; -import {writeFile} from '../lib/helpers'; +import {writeFile, getTempDir} from '../lib/helpers'; import GithubPackage from '../lib/github-package'; describe('GithubPackage', function() { @@ -33,30 +33,23 @@ describe('GithubPackage', function() { atomEnv.destroy(); }); - function untilWorkdirs(...workdirs) { - return until(`working directories ${workdirs.join(', ')} are loaded`, () => { - return workdirs.every(workdir => contextPool.getContext(workdir).isPresent()); - }); - } - - function untilRepositories(...workdirs) { - return untilWorkdirs(...workdirs).then(() => Promise.all( - workdirs.map(workdir => contextPool.getContext(workdir).getRepositoryPromise()), - )); - } - describe('activate()', function() { it('uses models from preexisting projects', async function() { - const [workdirPath1, workdirPath2] = await Promise.all([ + const [workdirPath1, workdirPath2, nonRepositoryPath] = await Promise.all([ cloneRepository('three-files'), cloneRepository('three-files'), + getTempDir(), ]); - project.setPaths([workdirPath1, workdirPath2]); + project.setPaths([workdirPath1, workdirPath2, nonRepositoryPath]); await githubPackage.activate(); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); + + assert.isTrue(contextPool.getContext(workdirPath1).isPresent()); + assert.isTrue(contextPool.getContext(workdirPath2).isPresent()); + assert.isTrue(contextPool.getContext(nonRepositoryPath).isPresent()); - await untilRepositories(workdirPath1, workdirPath2); - assert.isNull(githubPackage.getActiveRepository()); + assert.isTrue(githubPackage.getActiveRepository().isAbsent()); }); it('uses an active model from a single preexisting project', async function() { @@ -64,8 +57,13 @@ describe('GithubPackage', function() { project.setPaths([workdirPath]); await githubPackage.activate(); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); - await assert.async.isNotNull(githubPackage.getActiveRepository()); + const context = contextPool.getContext(workdirPath); + assert.isTrue(context.isPresent()); + + assert.strictEqual(context.getRepository(), githubPackage.getActiveRepository()); + assert.strictEqual(context.getResolutionProgress(), githubPackage.getActiveResolutionProgress()); assert.equal(githubPackage.getActiveWorkdir(), workdirPath); }); @@ -78,8 +76,12 @@ describe('GithubPackage', function() { await workspace.open(path.join(workdirPath2, 'a.txt')); await githubPackage.activate(); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); - await assert.async.isNotNull(githubPackage.getActiveRepository()); + const context = contextPool.getContext(workdirPath2); + assert.isTrue(context.isPresent()); + assert.strictEqual(context.getRepository(), githubPackage.getActiveRepository()); + assert.strictEqual(context.getResolutionProgress(), githubPackage.getActiveResolutionProgress()); assert.equal(githubPackage.getActiveWorkdir(), workdirPath2); }); @@ -94,8 +96,12 @@ describe('GithubPackage', function() { await githubPackage.activate({ activeRepositoryPath: workdirPath2, }); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); - await assert.async.isNotNull(githubPackage.getActiveRepository()); + const context = contextPool.getContext(workdirPath2); + assert.isTrue(context.isPresent()); + assert.strictEqual(context.getRepository(), githubPackage.getActiveRepository()); + assert.strictEqual(context.getResolutionProgress(), githubPackage.getActiveResolutionProgress()); assert.equal(githubPackage.getActiveWorkdir(), workdirPath2); }); @@ -110,8 +116,12 @@ describe('GithubPackage', function() { await githubPackage.activate({ activeRepositoryPath: workdirPath1, }); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); - await assert.async.isNotNull(githubPackage.getActiveRepository()); + const context = contextPool.getContext(workdirPath2); + assert.isTrue(context.isPresent()); + assert.strictEqual(context.getRepository(), githubPackage.getActiveRepository()); + assert.strictEqual(context.getResolutionProgress(), githubPackage.getActiveResolutionProgress()); assert.equal(githubPackage.getActiveWorkdir(), workdirPath2); }); @@ -125,8 +135,12 @@ describe('GithubPackage', function() { await githubPackage.activate({ activeRepositoryPath: workdirPath2, }); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); - await assert.async.isNotNull(githubPackage.getActiveRepository()); + const context = contextPool.getContext(workdirPath1); + assert.isTrue(context.isPresent()); + assert.strictEqual(context.getRepository(), githubPackage.getActiveRepository()); + assert.strictEqual(context.getResolutionProgress(), githubPackage.getActiveResolutionProgress()); assert.equal(githubPackage.getActiveWorkdir(), workdirPath1); }); @@ -139,12 +153,13 @@ describe('GithubPackage', function() { project.setPaths([workdirMergeConflict, workdirNoConflict, nonRepositoryPath]); await githubPackage.activate(); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); // Open a file in the merge conflict repository. await workspace.open(path.join(workdirMergeConflict, 'modified-on-both-ours.txt')); await githubPackage.scheduleActiveContextUpdate(); - const resolutionMergeConflict = await contextPool.getContext(workdirMergeConflict).getResolutionProgressPromise(); + const resolutionMergeConflict = contextPool.getContext(workdirMergeConflict).getResolutionProgress(); await assert.strictEqual(githubPackage.getActiveResolutionProgress(), resolutionMergeConflict); // Record some resolution progress to recall later @@ -154,7 +169,7 @@ describe('GithubPackage', function() { await workspace.open(path.join(workdirNoConflict, 'b.txt')); await githubPackage.scheduleActiveContextUpdate(); - const resolutionNoConflict = await contextPool.getContext(workdirNoConflict).getResolutionProgressPromise(); + const resolutionNoConflict = contextPool.getContext(workdirNoConflict).getResolutionProgress(); assert.strictEqual(githubPackage.getActiveResolutionProgress(), resolutionNoConflict); assert.isTrue(githubPackage.getActiveResolutionProgress().isEmpty()); @@ -162,7 +177,7 @@ describe('GithubPackage', function() { await workspace.open(path.join(nonRepositoryPath, 'c.txt')); await githubPackage.scheduleActiveContextUpdate(); - assert.isNull(githubPackage.getActiveResolutionProgress()); + assert.isTrue(githubPackage.getActiveResolutionProgress().isEmpty()); // Re-open a file in the merge conflict repository. await workspace.open(path.join(workdirMergeConflict, 'modified-on-both-theirs.txt')); @@ -184,10 +199,18 @@ describe('GithubPackage', function() { project.setPaths([workdirPath1, workdirPath2]); await githubPackage.activate(); - await untilWorkdirs(workdirPath1, workdirPath2); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); + + assert.isTrue(contextPool.getContext(workdirPath1).isPresent()); + assert.isTrue(contextPool.getContext(workdirPath2).isPresent()); + assert.isFalse(contextPool.getContext(workdirPath3).isPresent()); project.setPaths([workdirPath1, workdirPath2, workdirPath3]); - await untilWorkdirs(workdirPath1, workdirPath2, workdirPath3); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); + + assert.isTrue(contextPool.getContext(workdirPath1).isPresent()); + assert.isTrue(contextPool.getContext(workdirPath2).isPresent()); + assert.isTrue(contextPool.getContext(workdirPath3).isPresent()); }); it('destroys contexts associated with the removed project folders', async function() { @@ -198,13 +221,11 @@ describe('GithubPackage', function() { ]); project.setPaths([workdirPath1, workdirPath2, workdirPath3]); await githubPackage.activate(); - await untilRepositories(workdirPath1, workdirPath2, workdirPath3); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); - const [repository1, repository2, repository3] = await Promise.all( - [workdirPath1, workdirPath2, workdirPath3].map(workdir => { - return contextPool.getContext(workdir).getRepositoryPromise(); - }), - ); + const [repository1, repository2, repository3] = [workdirPath1, workdirPath2, workdirPath3].map(workdir => { + return contextPool.getContext(workdir).getRepository(); + }); sinon.stub(repository1, 'destroy'); sinon.stub(repository2, 'destroy'); @@ -212,9 +233,10 @@ describe('GithubPackage', function() { project.removePath(workdirPath1); project.removePath(workdirPath3); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); - await assert.async.equal(repository1.destroy.callCount, 1); - await assert.async.equal(repository3.destroy.callCount, 1); + assert.equal(repository1.destroy.callCount, 1); + assert.equal(repository3.destroy.callCount, 1); assert.isFalse(repository2.destroy.called); assert.isFalse(contextPool.getContext(workdirPath1).isPresent()); @@ -231,14 +253,14 @@ describe('GithubPackage', function() { ]); project.setPaths([workdirPath1, workdirPath2]); await githubPackage.activate(); - await untilRepositories(workdirPath1, workdirPath2); const repository2 = contextPool.getContext(workdirPath2).getRepository(); - assert.isNull(githubPackage.getActiveRepository()); + assert.isTrue(githubPackage.getActiveRepository().isAbsent()); - await workspace.open(path.join(workdirPath2, 'b.txt')); + workspace.open(path.join(workdirPath2, 'b.txt')); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); - await assert.async.strictEqual(githubPackage.getActiveRepository(), repository2); + assert.strictEqual(githubPackage.getActiveRepository(), repository2); }); it('adds a new context if not in a project', async function() { @@ -247,12 +269,17 @@ describe('GithubPackage', function() { cloneRepository('three-files'), ]); project.setPaths([workdirPath1]); + await githubPackage.activate(); - await untilRepositories(workdirPath1); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); - await workspace.open(path.join(workdirPath2, 'c.txt')); + assert.isFalse(contextPool.getContext(workdirPath2).isPresent()); - await assert.async.equal(githubPackage.getActiveWorkdir(), workdirPath2); + workspace.open(path.join(workdirPath2, 'c.txt')); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); + + assert.equal(githubPackage.getActiveWorkdir(), workdirPath2); + assert.isTrue(contextPool.getContext(workdirPath2).isPresent()); }); it('removes a context if not in a project', async function() { @@ -262,12 +289,13 @@ describe('GithubPackage', function() { ]); project.setPaths([workdirPath1]); await githubPackage.activate(); - await workspace.open(path.join(workdirPath2, 'c.txt')); - await untilRepositories(workdirPath1, workdirPath2); + + workspace.open(path.join(workdirPath2, 'c.txt')); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); workspace.getActivePane().destroyActiveItem(); - await assert.async.isFalse(contextPool.getContext(workdirPath2).isPresent()); + assert.isFalse(contextPool.getContext(workdirPath2).isPresent()); }); }); @@ -302,7 +330,7 @@ describe('GithubPackage', function() { await githubPackage.scheduleActiveContextUpdate(); - assert.isNull(githubPackage.getActiveRepository()); + assert.isTrue(githubPackage.getActiveRepository().isAbsent()); }); it('uses the context of a single open project', async function() { @@ -325,7 +353,8 @@ describe('GithubPackage', function() { await githubPackage.scheduleActiveContextUpdate(); - assert.isNull(githubPackage.getActiveRepository()); + assert.isFalse(contextPool.getContext(nonRepositoryPath).isPresent()); + assert.isTrue(githubPackage.getActiveRepository().isEmpty()); }); it('activates a saved context state', async function() { @@ -373,12 +402,12 @@ describe('GithubPackage', function() { await githubPackage.scheduleActiveContextUpdate(); - const repository = await contextPool.getContext(workdirPath).getRepositoryPromise(); - assert.isNotNull(repository); + assert.isTrue(contextPool.getContext(workdirPath).isPresent()); + const repository = contextPool.getContext(workdirPath).getRepository(); repository.destroy(); - assert.isNull(githubPackage.getActiveRepository()); + assert.isTrue(githubPackage.getActiveRepository().isAbsent()); }); // Don't worry about this on Windows as it's not a common op @@ -411,11 +440,10 @@ describe('GithubPackage', function() { project.setPaths([workdirPath1, workdirPath2]); await githubPackage.activate(); - await until('change observers have started', async () => { - const observers = await Promise.all([workdirPath1, workdirPath2].map(workdir => { - return contextPool.getContext(workdir).getChangeObserverPromise(); - })); - return observers.every(observer => observer && observer.started); + await until('change observers have started', () => { + return [workdirPath1, workdirPath2].every(workdir => { + return contextPool.getContext(workdir).getChangeObserver().isStarted(); + }); }); [atomGitRepository1, atomGitRepository2] = githubPackage.project.getRepositories(); @@ -467,19 +495,18 @@ describe('GithubPackage', function() { describe('createRepositoryForProjectPath()', function() { it('creates and sets a repository for the given project path', async function() { - const workdirPath1 = await cloneRepository('three-files'); - const workdirPath2 = await cloneRepository('three-files'); - const nonRepositoryPath = fs.realpathSync(temp.mkdirSync()); - fs.writeFileSync(path.join(nonRepositoryPath, 'c.txt')); - project.setPaths([workdirPath1, workdirPath2, nonRepositoryPath]); + const nonRepositoryPath = await getTempDir(); + project.setPaths([nonRepositoryPath]); + await githubPackage.activate(); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); + + assert.isTrue(githubPackage.getActiveRepository().isEmpty()); + assert.isFalse(githubPackage.getActiveRepository().isAbsent()); - await workspace.open(path.join(nonRepositoryPath, 'c.txt')); - await assert.async.isNull(githubPackage.getActiveRepository()); await githubPackage.createRepositoryForProjectPath(nonRepositoryPath); - await contextPool.getContext(nonRepositoryPath).getRepositoryPromise(); - assert.isOk(githubPackage.getActiveRepository()); + assert.isTrue(githubPackage.getActiveRepository().isPresent()); assert.strictEqual( githubPackage.getActiveRepository(), await contextPool.getContext(nonRepositoryPath).getRepository(), @@ -489,7 +516,7 @@ describe('GithubPackage', function() { describe('serialized state', function() { function resolutionProgressFrom(pkg, workdir) { - return pkg.getContextPool().getContext(workdir).getResolutionProgressPromise(); + return pkg.getContextPool().getContext(workdir).getResolutionProgress(); } it('restores nonempty resolution progress', async function() { @@ -501,7 +528,8 @@ describe('GithubPackage', function() { await githubPackage.scheduleActiveContextUpdate(); // Record some state to recover later. - const resolutionMergeConflict0 = await resolutionProgressFrom(githubPackage, workdirMergeConflict); + const resolutionMergeConflict0 = resolutionProgressFrom(githubPackage, workdirMergeConflict); + await assert.async.isTrue(resolutionMergeConflict0.loaded); resolutionMergeConflict0.reportMarkerCount('modified-on-both-ours.txt', 3); const payload = githubPackage.serialize(); @@ -517,10 +545,10 @@ describe('GithubPackage', function() { await githubPackage1.activate(payload); await githubPackage1.scheduleActiveContextUpdate(); - const resolutionMergeConflict1 = await resolutionProgressFrom(githubPackage1, workdirMergeConflict); - const resolutionNoConflict1 = await resolutionProgressFrom(githubPackage1, workdirNoConflict); + const resolutionMergeConflict1 = resolutionProgressFrom(githubPackage1, workdirMergeConflict); + const resolutionNoConflict1 = resolutionProgressFrom(githubPackage1, workdirNoConflict); - assert.isFalse(resolutionMergeConflict1.isEmpty()); + await assert.async.isFalse(resolutionMergeConflict1.isEmpty()); assert.equal(resolutionMergeConflict1.getRemaining('modified-on-both-ours.txt'), 3); assert.isTrue(resolutionNoConflict1.isEmpty()); From b6dc78164faf4dda8dc58767cdc039c4e6295e4c Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 14:55:59 -0400 Subject: [PATCH 101/161] Wait for context to finish updating --- test/github-package.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/github-package.test.js b/test/github-package.test.js index 097371aa1a..febdabad51 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -252,13 +252,17 @@ describe('GithubPackage', function() { cloneRepository('three-files'), ]); project.setPaths([workdirPath1, workdirPath2]); + await githubPackage.activate(); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); + const repository2 = contextPool.getContext(workdirPath2).getRepository(); assert.isTrue(githubPackage.getActiveRepository().isAbsent()); + const updatePromise = githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); workspace.open(path.join(workdirPath2, 'b.txt')); - await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); + await updatePromise; assert.strictEqual(githubPackage.getActiveRepository(), repository2); }); From a1bffc331099643f21ff6155eb5726b8d45bd9e1 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 15:29:21 -0400 Subject: [PATCH 102/161] Boolean logic is hard, okay --- lib/models/repository.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/repository.js b/lib/models/repository.js index 1ad962de08..1058d1a8cf 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -71,7 +71,7 @@ export default class Repository { } getLoadPromise() { - return this.isAbsent() ? this.loadPromise : Promise.reject(new Error('An absent repository will never load')); + return this.isAbsent() ? Promise.reject(new Error('An absent repository will never load')) : this.loadPromise; } /* From 54d0da7e251d8eb0bb34492d879762f251921344 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 17 Apr 2017 15:29:42 -0400 Subject: [PATCH 103/161] Await context updates correctly --- test/github-package.test.js | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/test/github-package.test.js b/test/github-package.test.js index febdabad51..bec0ddbe82 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -33,6 +33,12 @@ describe('GithubPackage', function() { atomEnv.destroy(); }); + function contextUpdateAfter(chunk) { + const updatePromise = githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); + chunk(); + return updatePromise; + } + describe('activate()', function() { it('uses models from preexisting projects', async function() { const [workdirPath1, workdirPath2, nonRepositoryPath] = await Promise.all([ @@ -279,8 +285,7 @@ describe('GithubPackage', function() { assert.isFalse(contextPool.getContext(workdirPath2).isPresent()); - workspace.open(path.join(workdirPath2, 'c.txt')); - await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); + await contextUpdateAfter(() => workspace.open(path.join(workdirPath2, 'c.txt'))); assert.equal(githubPackage.getActiveWorkdir(), workdirPath2); assert.isTrue(contextPool.getContext(workdirPath2).isPresent()); @@ -293,12 +298,12 @@ describe('GithubPackage', function() { ]); project.setPaths([workdirPath1]); await githubPackage.activate(); - - workspace.open(path.join(workdirPath2, 'c.txt')); await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); - workspace.getActivePane().destroyActiveItem(); + await contextUpdateAfter(() => workspace.open(path.join(workdirPath2, 'c.txt'))); + assert.isTrue(contextPool.getContext(workdirPath2).isPresent()); + await contextUpdateAfter(() => workspace.getActivePane().destroyActiveItem()); assert.isFalse(contextPool.getContext(workdirPath2).isPresent()); }); }); @@ -325,12 +330,13 @@ describe('GithubPackage', function() { assert.equal(githubPackage.getActiveWorkdir(), workdirPath2); }); - it('uses an empty context when the active item is not in a git repository', async function() { + it('uses an absent context when the active item is not in a git repository', async function() { const nonRepositoryPath = fs.realpathSync(temp.mkdirSync()); const workdir = await cloneRepository('three-files'); project.setPaths([nonRepositoryPath, workdir]); await writeFile(path.join(nonRepositoryPath, 'a.txt'), 'stuff'); - await workspace.open(path.join(nonRepositoryPath, 'a.txt')); + + workspace.open(path.join(nonRepositoryPath, 'a.txt')); await githubPackage.scheduleActiveContextUpdate(); @@ -351,14 +357,16 @@ describe('GithubPackage', function() { assert.equal(githubPackage.getActiveWorkdir(), workdirPath1); }); - it('uses a null context with a single open project without a git workdir', async function() { - const nonRepositoryPath = fs.realpathSync(temp.mkdirSync()); + it('uses an empty context with a single open project without a git workdir', async function() { + const nonRepositoryPath = await getTempDir(); project.setPaths([nonRepositoryPath]); await githubPackage.scheduleActiveContextUpdate(); + await githubPackage.getActiveRepository().getLoadPromise(); - assert.isFalse(contextPool.getContext(nonRepositoryPath).isPresent()); + assert.isTrue(contextPool.getContext(nonRepositoryPath).isPresent()); assert.isTrue(githubPackage.getActiveRepository().isEmpty()); + assert.isFalse(githubPackage.getActiveRepository().isAbsent()); }); it('activates a saved context state', async function() { @@ -504,6 +512,7 @@ describe('GithubPackage', function() { await githubPackage.activate(); await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); + await githubPackage.getActiveRepository().getLoadPromise(); assert.isTrue(githubPackage.getActiveRepository().isEmpty()); assert.isFalse(githubPackage.getActiveRepository().isAbsent()); From 730b5b3d0b364ed10592af498c6c35165024f1b1 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 11:08:43 -0400 Subject: [PATCH 104/161] getTempDir() generates the parent directory itself --- lib/models/discard-history.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/discard-history.js b/lib/models/discard-history.js index 7c71687522..53ca3c2f6b 100644 --- a/lib/models/discard-history.js +++ b/lib/models/discard-history.js @@ -118,7 +118,7 @@ export default class DiscardHistory { } async expandBlobsToFilesInTempFolder(snapshots) { - const tempFolderPath = await getTempDir(path.join(os.tmpdir(), 'github-discard-history-')); + const tempFolderPath = await getTempDir('github-discard-history-'); const pathPromises = snapshots.map(async ({filePath, beforeSha, afterSha}) => { const dir = path.dirname(path.join(tempFolderPath, filePath)); await mkdirp(dir); From 72d5f1716417d5cce28b4cc9654e68d6c03a08a9 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 11:09:02 -0400 Subject: [PATCH 105/161] DiscardHistory requires a working directory path, not a function --- lib/models/repository-states/present.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index be44c1b0a7..be6fe97a5e 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -72,7 +72,7 @@ export default class Present extends State { this.createBlob.bind(this), this.expandBlobToFile.bind(this), this.mergeFile.bind(this), - this.workdir.bind(this), + this.workdir(), {maxHistoryLength: 60}, ); } From ed6955690f69007a931d65b072033e1abe84b2d7 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 11:09:26 -0400 Subject: [PATCH 106/161] Avoid PropTypes warnings by providing null models --- test/controllers/root-controller.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index c12a3ef049..dfa44c1cb1 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -8,6 +8,8 @@ import dedent from 'dedent-js'; import {cloneRepository, buildRepository} from '../helpers'; import {writeFile} from '../../lib/helpers'; import {GitError} from '../../lib/git-shell-out-strategy'; +import Repository from '../../lib/models/repository'; +import ResolutionProgress from '../../lib/models/conflicts/resolution-progress'; import RootController from '../../lib/controllers/root-controller'; @@ -25,6 +27,9 @@ describe('RootController', function() { workspaceElement = atomEnv.views.getView(workspace); + const absentRepository = Repository.absent(); + const emptyResolutionProgress = new ResolutionProgress(); + confirm = sinon.stub(atomEnv, 'confirm'); app = ( ); }); @@ -636,6 +643,7 @@ describe('RootController', function() { const contents2 = fs.readFileSync(absFilePath, 'utf8'); assert.notEqual(contents1, contents2); await repository.refresh(); + unstagedFilePatch = await repository.getFilePatchForPath('sample.js'); wrapper.setState({filePatch: unstagedFilePatch}); await wrapper.instance().discardLines(new Set(unstagedFilePatch.getHunks()[0].getLines().slice(2, 4))); From 76d9c1919afd6e260f4be8bcb02fd179cf3764fb Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 11:09:43 -0400 Subject: [PATCH 107/161] Wait for checkout operations to complete --- test/controllers/status-bar-tile-controller.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/controllers/status-bar-tile-controller.test.js b/test/controllers/status-bar-tile-controller.test.js index 8feedd44ff..4a82f768b4 100644 --- a/test/controllers/status-bar-tile-controller.test.js +++ b/test/controllers/status-bar-tile-controller.test.js @@ -103,6 +103,8 @@ describe('StatusBarTileController', function() { assert.equal(tip.querySelector('select').value, 'master'); selectOption(tip, 'branch'); + await assert.async.isFalse(wrapper.instance().getWrappedComponentInstance().state.inProgress); + const branch1 = await repository.getCurrentBranch(); assert.equal(branch1.getName(), 'branch'); assert.isFalse(branch1.isDetached()); @@ -111,6 +113,8 @@ describe('StatusBarTileController', function() { assert.equal(tip.querySelector('select').value, 'branch'); selectOption(tip, 'master'); + await assert.async.isFalse(wrapper.instance().getWrappedComponentInstance().state.inProgress); + const branch2 = await repository.getCurrentBranch(); assert.equal(branch2.getName(), 'master'); assert.isFalse(branch2.isDetached()); From 8268a7190d9cc3fbabea1f97399496ec2b41a8ab Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 11:25:52 -0400 Subject: [PATCH 108/161] Because I changed the behavior of getTempDir for some reason --- lib/git-prompt-server.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/git-prompt-server.js b/lib/git-prompt-server.js index dcda296843..8c62ad3fa1 100644 --- a/lib/git-prompt-server.js +++ b/lib/git-prompt-server.js @@ -1,6 +1,5 @@ import net from 'net'; import path from 'path'; -import os from 'os'; import {Emitter} from 'event-kit'; @@ -24,8 +23,7 @@ export default class GitPromptServer { // TODO: [mkt] Windows?? yes. this.promptForInput = promptForInput; const windows = process.platform === 'win32'; - const prefix = windows ? os.tmpdir() : '/tmp'; - this.tmpFolderPath = await getTempDir(path.join(prefix, 'github-')); + this.tmpFolderPath = await getTempDir('github-'); const credentialHelper = {}; const askPass = {}; From b23c87733a92e46a51b7aa231491515b54fba175 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 14:27:04 -0400 Subject: [PATCH 109/161] Don't conceal errors --- lib/controllers/git-tab-controller.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/controllers/git-tab-controller.js b/lib/controllers/git-tab-controller.js index 96e30e5a28..94beda44fe 100644 --- a/lib/controllers/git-tab-controller.js +++ b/lib/controllers/git-tab-controller.js @@ -275,6 +275,8 @@ export default class GitTabController { `Cannot abort because ${e.path} is both dirty and staged.`, {dismissable: true}, ); + } else { + throw e; } } return etch.update(this); From 0ea1b57f7ba8f893fa10b1b3f2203388358690a5 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 14:27:19 -0400 Subject: [PATCH 110/161] Always return a Promise from stageFiles and unstageFiles --- lib/git-shell-out-strategy.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index 7324fcfa8e..fb847ea135 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -236,13 +236,13 @@ export default class GitShellOutStrategy { * Staging/Unstaging files and patches and committing */ stageFiles(paths) { - if (paths.length === 0) { return null; } + if (paths.length === 0) { return Promise.resolve(null); } const args = ['add'].concat(paths); return this.exec(args, {writeOperation: true}); } unstageFiles(paths, commit = 'HEAD') { - if (paths.length === 0) { return null; } + if (paths.length === 0) { return Promise.resolve(null); } const args = ['reset', commit, '--'].concat(paths); return this.exec(args, {writeOperation: true}); } From 1b8e85f0112577f670a17cd17567b4b9ca87097d Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 14:27:33 -0400 Subject: [PATCH 111/161] Use .refresh() for invalidation to trigger didUpdate --- lib/models/repository-states/present.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index be6fe97a5e..ade6925359 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -45,15 +45,13 @@ function invalidate() { return function(target, name, descriptor) { const original = descriptor.value; descriptor.value = function(...args) { - const cache = this.cache; - return original.apply(this, args).then( result => { - cache.clear(); + this.refresh(); return result; }, err => { - cache.clear(); + this.refresh(); return Promise.reject(err); }, ); From bc70ab9c3e4f8d7da1d18503ae36621930f69e3a Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 14:27:46 -0400 Subject: [PATCH 112/161] Await props.commit too --- lib/views/commit-view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/views/commit-view.js b/lib/views/commit-view.js index 6a5c85dc1b..dd91bab111 100644 --- a/lib/views/commit-view.js +++ b/lib/views/commit-view.js @@ -106,7 +106,7 @@ export default class CommitView { @autobind async commit() { if (await this.props.prepareToCommit() && this.isCommitButtonEnabled()) { - this.props.commit(this.editor.getText()); + await this.props.commit(this.editor.getText()); } else { this.focus(); } From 8c5c5fedf4c5153632fe1a3b626412b4120e05a2 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 14:28:09 -0400 Subject: [PATCH 113/161] :fire: that doesn't need to be a thing any more --- test/models/workdir-context-pool.test.js | 28 ------------------------ 1 file changed, 28 deletions(-) diff --git a/test/models/workdir-context-pool.test.js b/test/models/workdir-context-pool.test.js index 25ca8a4552..783feef386 100644 --- a/test/models/workdir-context-pool.test.js +++ b/test/models/workdir-context-pool.test.js @@ -42,15 +42,6 @@ describe('WorkdirContextPool', function() { assert.isTrue(pool.getContext(workingDirectory).isPresent()); }); - it('optionally provides a preinitialized repository', function() { - const existingRepo = new Repository(workingDirectory); - - pool.add(workingDirectory, {repository: existingRepo}); - - const addedRepo = pool.getContext(workingDirectory).getRepository(); - assert.strictEqual(addedRepo, existingRepo); - }); - it('is a no-op if the working directory already has a context', function() { pool.add(workingDirectory); assert.equal(pool.size(), 1); @@ -119,25 +110,6 @@ describe('WorkdirContextPool', function() { assert.notStrictEqual(original, replaced); }); - - it('passes a Repository to the created WorkdirContext', async function() { - const directory = await cloneRepository('three-files'); - - pool.add(directory); - assert.equal(pool.size(), 1); - assert.isTrue(pool.getContext(directory).isPresent()); - const original = pool.getContext(directory).getRepository(); - - const replacement = await buildRepository(directory); - - pool.replace(directory, {repository: replacement}); - assert.equal(pool.size(), 1); - assert.isTrue(pool.getContext(directory).isPresent()); - const replaced = pool.getContext(directory).getRepository(); - - assert.notStrictEqual(original, replaced); - assert.strictEqual(replacement, replaced); - }); }); describe('remove', function() { From 6570cdb042aa91141f1599de93cb3242d8b8ff21 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 14:34:28 -0400 Subject: [PATCH 114/161] Prefer "await asser.async" to the "next render" promises --- test/controllers/git-tab-controller.test.js | 116 +++++++++----------- 1 file changed, 50 insertions(+), 66 deletions(-) diff --git a/test/controllers/git-tab-controller.test.js b/test/controllers/git-tab-controller.test.js index cfaa611b75..e9ed77bd59 100644 --- a/test/controllers/git-tab-controller.test.js +++ b/test/controllers/git-tab-controller.test.js @@ -8,7 +8,7 @@ import until from 'test-until'; import GitTabController from '../../lib/controllers/git-tab-controller'; import {cloneRepository, buildRepository} from '../helpers'; -import {AbortMergeError, CommitError} from '../../lib/models/repository'; +import Repository, {AbortMergeError, CommitError} from '../../lib/models/repository'; import ResolutionProgress from '../../lib/models/conflicts/resolution-progress'; describe('GitTabController', function() { @@ -59,16 +59,17 @@ describe('GitTabController', function() { const repository1 = await buildRepository(workdirPath1); const workdirPath2 = await cloneRepository('three-files'); const repository2 = await buildRepository(workdirPath2); + fs.writeFileSync(path.join(workdirPath1, 'a.txt'), 'a change\n'); fs.unlinkSync(path.join(workdirPath1, 'b.txt')); const controller = new GitTabController({ workspace, commandRegistry, resolutionProgress, refreshResolutionProgress, - repository: null, + repository: Repository.absent(), }); // Renders empty GitTabView when there is no active repository assert.isDefined(controller.refs.gitPanel); - assert.isNull(controller.getActiveRepository()); + assert.isTrue(controller.getActiveRepository().isAbsent()); assert.isDefined(controller.refs.gitPanel.refs.noRepoMessage); // Fetches data when a new repository is assigned @@ -156,9 +157,7 @@ describe('GitTabController', function() { const workdirPath = await cloneRepository('merge-conflict'); const repository = await buildRepository(workdirPath); - await repository.git.merge('origin/branch') - .then(() => { throw new Error('Expected merge to throw an error'); }) - .catch(() => true); + await assert.isRejected(repository.git.merge('origin/branch')); const controller = new GitTabController({ workspace, commandRegistry, repository, @@ -433,28 +432,26 @@ describe('GitTabController', function() { const stagingView = controller.refs.gitPanel.refs.stagingView; const commitView = controller.refs.gitPanel.refs.commitViewController.refs.commitView; - assert.equal(stagingView.props.unstagedChanges.length, 2); - assert.equal(stagingView.props.stagedChanges.length, 0); - await stagingView.dblclickOnItem({}, stagingView.props.unstagedChanges[0]).stageOperationPromise; - repository.refresh(); - await controller.getLastModelDataRefreshPromise(); - await etch.getScheduler().getNextUpdatePromise(); - await stagingView.dblclickOnItem({}, stagingView.props.unstagedChanges[0]).stageOperationPromise; - repository.refresh(); - await controller.getLastModelDataRefreshPromise(); - await etch.getScheduler().getNextUpdatePromise(); - assert.equal(stagingView.props.unstagedChanges.length, 0); - assert.equal(stagingView.props.stagedChanges.length, 2); - await stagingView.dblclickOnItem({}, stagingView.props.stagedChanges[1]).stageOperationPromise; - repository.refresh(); - await controller.getLastModelDataRefreshPromise(); - await etch.getScheduler().getNextUpdatePromise(); - assert.equal(stagingView.props.unstagedChanges.length, 1); - assert.equal(stagingView.props.stagedChanges.length, 1); + assert.lengthOf(stagingView.props.unstagedChanges, 2); + assert.lengthOf(stagingView.props.stagedChanges, 0); + + stagingView.dblclickOnItem({}, stagingView.props.unstagedChanges[0]); + + await assert.async.lengthOf(stagingView.props.unstagedChanges, 1); + assert.lengthOf(stagingView.props.stagedChanges, 1); + + stagingView.dblclickOnItem({}, stagingView.props.unstagedChanges[0]); + + await assert.async.lengthOf(stagingView.props.unstagedChanges, 0); + assert.lengthOf(stagingView.props.stagedChanges, 2); + + stagingView.dblclickOnItem({}, stagingView.props.stagedChanges[1]); + + await assert.async.lengthOf(stagingView.props.unstagedChanges, 1); + assert.lengthOf(stagingView.props.stagedChanges, 1); commitView.refs.editor.setText('Make it so'); await commitView.commit(); - await controller.getLastModelDataRefreshPromise(); assert.equal((await repository.getLastCommit()).getMessage(), 'Make it so'); }); @@ -463,65 +460,52 @@ describe('GitTabController', function() { const workdirPath = await cloneRepository('merge-conflict'); const repository = await buildRepository(workdirPath); - try { - await repository.git.merge('origin/branch'); - } catch (e) { - // expected - } + await assert.isRejected(repository.git.merge('origin/branch')); const controller = new GitTabController({ workspace, commandRegistry, repository, resolutionProgress, refreshResolutionProgress, }); - await controller.getLastModelDataRefreshPromise(); - await etch.getScheduler().getNextUpdatePromise(); - + await assert.async.isDefined(controller.refs.gitPanel.refs.stagingView); const stagingView = controller.refs.gitPanel.refs.stagingView; + assert.equal(stagingView.props.mergeConflicts.length, 5); assert.equal(stagingView.props.stagedChanges.length, 0); const conflict1 = stagingView.props.mergeConflicts.filter(c => c.filePath === 'modified-on-both-ours.txt')[0]; const contentsWithMarkers = fs.readFileSync(path.join(workdirPath, conflict1.filePath), 'utf8'); - assert(contentsWithMarkers.includes('>>>>>>>')); - assert(contentsWithMarkers.includes('<<<<<<<')); + assert.include(contentsWithMarkers, '>>>>>>>'); + assert.include(contentsWithMarkers, '<<<<<<<'); let choice; - sinon.stub(atom, 'confirm', () => { - return choice; - }); + sinon.stub(atom, 'confirm', () => choice); // click Cancel choice = 1; - await stagingView.dblclickOnItem({}, conflict1).stageOperationPromise; - repository.refresh(); - await controller.getLastModelDataRefreshPromise(); - await etch.getScheduler().getNextUpdatePromise(); - assert.equal(atom.confirm.calledOnce, true); - assert.equal(stagingView.props.mergeConflicts.length, 5); - assert.equal(stagingView.props.stagedChanges.length, 0); + await stagingView.dblclickOnItem({}, conflict1).selectionUpdatePromise; + + await assert.async.lengthOf(stagingView.props.mergeConflicts, 5); + assert.lengthOf(stagingView.props.stagedChanges, 0); + assert.isTrue(atom.confirm.calledOnce); // click Stage choice = 0; atom.confirm.reset(); - await stagingView.dblclickOnItem({}, conflict1).stageOperationPromise; - repository.refresh(); - await controller.getLastModelDataRefreshPromise(); - await etch.getScheduler().getNextUpdatePromise(); - assert.equal(atom.confirm.calledOnce, true); - assert.equal(stagingView.props.mergeConflicts.length, 4); - assert.equal(stagingView.props.stagedChanges.length, 1); + await stagingView.dblclickOnItem({}, conflict1).selectionUpdatePromise; + + await assert.async.lengthOf(stagingView.props.mergeConflicts, 4); + assert.lengthOf(stagingView.props.stagedChanges, 1); + assert.isTrue(atom.confirm.calledOnce); // clear merge markers const conflict2 = stagingView.props.mergeConflicts.filter(c => c.filePath === 'modified-on-both-theirs.txt')[0]; atom.confirm.reset(); fs.writeFileSync(path.join(workdirPath, conflict2.filePath), 'text with no merge markers'); - await stagingView.dblclickOnItem({}, conflict2).stageOperationPromise; - repository.refresh(); - await controller.getLastModelDataRefreshPromise(); - await etch.getScheduler().getNextUpdatePromise(); - assert.equal(atom.confirm.called, false); - assert.equal(stagingView.props.mergeConflicts.length, 3); - assert.equal(stagingView.props.stagedChanges.length, 2); + stagingView.dblclickOnItem({}, conflict2); + + await assert.async.lengthOf(stagingView.props.mergeConflicts, 3); + assert.lengthOf(stagingView.props.stagedChanges, 2); + assert.isFalse(atom.confirm.called); }); it('avoids conflicts with pending file staging operations', async function() { @@ -532,11 +516,11 @@ describe('GitTabController', function() { const controller = new GitTabController({ workspace, commandRegistry, repository, resolutionProgress, refreshResolutionProgress, }); - await controller.getLastModelDataRefreshPromise(); - await etch.getScheduler().getNextUpdatePromise(); + + await assert.async.isDefined(controller.refs.gitPanel.refs.stagingView); const stagingView = controller.refs.gitPanel.refs.stagingView; - assert.equal(stagingView.props.unstagedChanges.length, 2); + assert.lengthOf(stagingView.props.unstagedChanges, 2); // ensure staging the same file twice does not cause issues // second stage action is a no-op since the first staging operation is in flight @@ -544,15 +528,15 @@ describe('GitTabController', function() { stagingView.confirmSelectedItems(); await file1StagingPromises.stageOperationPromise; - repository.refresh(); await file1StagingPromises.selectionUpdatePromise; - assert.equal(stagingView.props.unstagedChanges.length, 1); + + await assert.async.lengthOf(stagingView.props.unstagedChanges, 1); const file2StagingPromises = stagingView.confirmSelectedItems(); await file2StagingPromises.stageOperationPromise; - repository.refresh(); await file2StagingPromises.selectionUpdatePromise; - assert.equal(stagingView.props.unstagedChanges.length, 0); + + await assert.async.lengthOf(stagingView.props.unstagedChanges, 0); }); it('updates file status and paths when changed', async function() { From 62cb71b41fd032d524f06898d3f189bd7b3d70cc Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 14:44:45 -0400 Subject: [PATCH 115/161] ResolutionProgress constructor no longer accepts arguments --- test/views/staging-view.test.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/views/staging-view.test.js b/test/views/staging-view.test.js index 753109f106..ab7269a5b8 100644 --- a/test/views/staging-view.test.js +++ b/test/views/staging-view.test.js @@ -101,7 +101,8 @@ describe('StagingView', function() { status: {file: 'modified', ours: 'deleted', theirs: 'modified'}, }]; - const resolutionProgress = new ResolutionProgress('abcd1234', {}); + const resolutionProgress = new ResolutionProgress(); + resolutionProgress.load('abcd1234', {}); const view = new StagingView({ workingDirectoryPath, @@ -125,7 +126,8 @@ describe('StagingView', function() { status: {file: 'modified', ours: 'deleted', theirs: 'modified'}, }]; - const resolutionProgress = new ResolutionProgress('abcd1234', { + const resolutionProgress = new ResolutionProgress(); + resolutionProgress.load('abcd1234', { revision: 'abcd1234', paths: { [path.join(workingDirectoryPath, 'conflicted-path')]: 10, @@ -154,7 +156,8 @@ describe('StagingView', function() { status: {file: 'modified', ours: 'deleted', theirs: 'modified'}, }]; - const resolutionProgress = new ResolutionProgress('abcd1234', { + const resolutionProgress = new ResolutionProgress(); + resolutionProgress.load('abcd1234', { revision: 'abcd1234', paths: { [path.join(workingDirectoryPath, 'conflicted-path')]: 0, @@ -186,7 +189,8 @@ describe('StagingView', function() { }, ]; - const resolutionProgress = new ResolutionProgress('abcd1234', { + const resolutionProgress = new ResolutionProgress(); + resolutionProgress.load('abcd1234', { revision: 'abcd1234', paths: { [path.join(workingDirectoryPath, 'conflicted-path-0.txt')]: 2, @@ -218,7 +222,8 @@ describe('StagingView', function() { status: {file: 'modified', ours: 'deleted', theirs: 'modified'}, }]; - const resolutionProgress = new ResolutionProgress('abcd1234', { + const resolutionProgress = new ResolutionProgress(); + resolutionProgress.load('abcd1234', { revision: 'abcd1234', paths: { [path.join(workingDirectoryPath, 'conflicted-path')]: 0, From d270367ae5d896a1053bbe53525616244dd086e7 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 14:48:11 -0400 Subject: [PATCH 116/161] PropTypes are our friend --- .../controllers/file-patch-controller.test.js | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/test/controllers/file-patch-controller.test.js b/test/controllers/file-patch-controller.test.js index 96012fcbef..3641e36546 100644 --- a/test/controllers/file-patch-controller.test.js +++ b/test/controllers/file-patch-controller.test.js @@ -9,6 +9,8 @@ import FilePatch from '../../lib/models/file-patch'; import FilePatchController from '../../lib/controllers/file-patch-controller'; import Hunk from '../../lib/models/hunk'; import HunkLine from '../../lib/models/hunk-line'; +import Repository from '../../lib/models/repository'; +import ResolutionProgress from '../../lib/models/conflicts/resolution-progress'; import Switchboard from '../../lib/switchboard'; describe('FilePatchController', function() { @@ -30,10 +32,14 @@ describe('FilePatchController', function() { openFiles = sinon.spy(); const filePatch = new FilePatch('a.txt', 'a.txt', 'modified', [new Hunk(1, 1, 1, 3, '', [])]); + const repository = Repository.absent(); + const resolutionProgress = new ResolutionProgress(); component = ( Date: Tue, 18 Apr 2017 15:01:50 -0400 Subject: [PATCH 117/161] destroy() takes more time on AppVeyor --- test/models/workdir-context.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/models/workdir-context.test.js b/test/models/workdir-context.test.js index 07748c599d..99a37114d4 100644 --- a/test/models/workdir-context.test.js +++ b/test/models/workdir-context.test.js @@ -110,7 +110,7 @@ describe('WorkdirContext', function() { await observer.start(); await context.destroy(); - assert.isFalse(observer.isStarted()); + await assert.async.isFalse(observer.isStarted()); }); it('can be destroyed twice', async function() { From 8b26484d7af7e2c05176d287a35f7e41979d643e Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 15:06:13 -0400 Subject: [PATCH 118/161] GitPanel :point_right: GitTab for consistency --- keymaps/git.cson | 4 +- lib/controllers/git-tab-controller.js | 14 ++-- lib/controllers/root-controller.js | 64 ++++++++--------- lib/controllers/status-bar-tile-controller.js | 6 +- lib/views/staging-view.js | 2 +- menus/git.cson | 4 +- test/controllers/git-tab-controller.test.js | 72 +++++++++---------- test/controllers/root-controller.test.js | 46 ++++++------ .../status-bar-tile-controller.test.js | 10 +-- 9 files changed, 111 insertions(+), 111 deletions(-) diff --git a/keymaps/git.cson b/keymaps/git.cson index 81abdfa803..98e30a2427 100644 --- a/keymaps/git.cson +++ b/keymaps/git.cson @@ -1,6 +1,6 @@ '.workspace': - 'ctrl-9': 'github:toggle-git-panel' - 'ctrl-(': 'github:toggle-git-panel-focus' # ctrl-shift-9 + 'ctrl-9': 'github:toggle-git-tab' + 'ctrl-(': 'github:toggle-git-tab-focus' # ctrl-shift-9 '.github-Panel': 'cmd-enter': 'github:commit' diff --git a/lib/controllers/git-tab-controller.js b/lib/controllers/git-tab-controller.js index 94beda44fe..53402d81ab 100644 --- a/lib/controllers/git-tab-controller.js +++ b/lib/controllers/git-tab-controller.js @@ -36,7 +36,7 @@ export default class GitTabController { const hasUndoHistory = this.props.repository ? this.hasUndoHistory() : false; return ( ); @@ -155,14 +155,14 @@ export default class RootController extends React.Component { renderPanels() { if (!this.props.useLegacyPanels) { - const gitPanel = this.state.gitPanelActive && ( + const gitTab = this.state.gitTabActive && ( subtree.getWrappedComponent()} - onDidCloseItem={() => this.setState({gitPanelActive: false})} + onDidCloseItem={() => this.setState({gitTabActive: false})} stubItemSelector="git-tab-controller"> { this.gitPanelController = c; }} + ref={c => { this.gitTabController = c; }} className="github-PanelEtchWrapper" reattachDomNode={false}> ); - return
    {gitPanel}{githubPanel}
    ; + return
    {gitTab}{githubPanel}
    ; } return ( this.setState({gitPanelActive: false})} - visible={!!this.state.gitPanelActive}> + onDidClosePanel={() => this.setState({gitTabActive: false})} + visible={!!this.state.gitTabActive}> { this.gitPanelController = c; }} + ref={c => { this.gitTabController = c; }} className="github-PanelEtchWrapper" reattachDomNode={false}> ({gitPanelActive: !state.gitPanelActive})); + toggleGitTab() { + this.setState(state => ({gitTabActive: !state.gitTabActive})); } @autobind @@ -447,16 +447,16 @@ export default class RootController extends React.Component { } @autobind - toggleGitPanelFocus() { - if (!this.state.gitPanelActive) { - this.setState({gitPanelActive: true}, () => this.toggleGitPanelFocus()); + toggleGitTabFocus() { + if (!this.state.gitTabActive) { + this.setState({gitTabActive: true}, () => this.toggleGitTabFocus()); return; } - if (this.gitPanelHasFocus()) { + if (this.gitTabHasFocus()) { this.props.workspace.getActivePane().activate(); } else { - this.focusGitPanel(); + this.focusGitTab(); } } @@ -466,21 +466,21 @@ export default class RootController extends React.Component { } @autobind - focusGitPanel() { - this.gitPanelController.getWrappedComponent().focus(); + focusGitTab() { + this.gitTabController.getWrappedComponent().focus(); } - gitPanelHasFocus() { - return this.gitPanelController.getWrappedComponent().isFocused(); + gitTabHasFocus() { + return this.gitTabController.getWrappedComponent().isFocused(); } // Ensure that the Git panel is visible. Returns a Promise that resolves to `true` if the panel was initially // hidden or `false` if it was already shown. @autobind - ensureGitPanel() { - if (!this.state.gitPanelActive) { + ensureGitTab() { + if (!this.state.gitTabActive) { return new Promise((resolve, reject) => { - this.setState({gitPanelActive: true}, () => resolve(true)); + this.setState({gitTabActive: true}, () => resolve(true)); }); } @@ -501,8 +501,8 @@ export default class RootController extends React.Component { @autobind quietlySelectItem(filePath, stagingStatus) { - if (this.gitPanelController) { - return this.gitPanelController.getWrappedComponent().quietlySelectItem(filePath, stagingStatus); + if (this.gitTabController) { + return this.gitTabController.getWrappedComponent().quietlySelectItem(filePath, stagingStatus); } else { return null; } diff --git a/lib/controllers/status-bar-tile-controller.js b/lib/controllers/status-bar-tile-controller.js index 9f6ea9d4d8..9ba9e2e08b 100644 --- a/lib/controllers/status-bar-tile-controller.js +++ b/lib/controllers/status-bar-tile-controller.js @@ -56,14 +56,14 @@ export default class StatusBarTileController extends React.Component { aheadCount: PropTypes.number, behindCount: PropTypes.number, changedFilesCount: PropTypes.number, - toggleGitPanel: PropTypes.func, + toggleGitTab: PropTypes.func, } static defaultProps = { currentBranch: nullBranch, branches: [], currentRemote: nullRemote, - toggleGitPanel: () => {}, + toggleGitTab: () => {}, } constructor(props, context) { @@ -142,7 +142,7 @@ export default class StatusBarTileController extends React.Component { />
    diff --git a/lib/views/staging-view.js b/lib/views/staging-view.js index 7a6a419983..cc4205f8ef 100644 --- a/lib/views/staging-view.js +++ b/lib/views/staging-view.js @@ -82,7 +82,7 @@ export default class StagingView { this.subscriptions.add(this.props.commandRegistry.add('atom-workspace', { 'github:stage-all': () => this.stageAll(), 'github:unstage-all': () => this.unstageAll(), - 'github:undo-last-discard-in-git-panel': () => this.props.hasUndoHistory && this.undoLastDiscard(), + 'github:undo-last-discard-in-git-tab': () => this.props.hasUndoHistory && this.undoLastDiscard(), })); this.subscriptions.add(this.props.commandRegistry.add(this.refs.unstagedChanges, { 'github:discard-changes-in-selected-files': () => this.discardChanges(), diff --git a/menus/git.cson b/menus/git.cson index 0c45c8628a..38334340ea 100644 --- a/menus/git.cson +++ b/menus/git.cson @@ -4,7 +4,7 @@ 'submenu': [ { 'label': 'Toggle Git Panel' - 'command': 'github:toggle-git-panel' + 'command': 'github:toggle-git-tab' } ] } @@ -16,7 +16,7 @@ 'submenu': [ { 'label': 'Toggle' - 'command': 'github:toggle-git-panel' + 'command': 'github:toggle-git-tab' } ] } diff --git a/test/controllers/git-tab-controller.test.js b/test/controllers/git-tab-controller.test.js index e9ed77bd59..18910e8514 100644 --- a/test/controllers/git-tab-controller.test.js +++ b/test/controllers/git-tab-controller.test.js @@ -43,15 +43,15 @@ describe('GitTabController', function() { }); assert.equal(controller.getActiveRepository(), repository); - assert.isDefined(controller.refs.gitPanel.refs.repoLoadingMessage); - assert.isUndefined(controller.refs.gitPanel.refs.stagingView); - assert.isUndefined(controller.refs.gitPanel.refs.commitView); + assert.isDefined(controller.refs.gitTab.refs.repoLoadingMessage); + assert.isUndefined(controller.refs.gitTab.refs.stagingView); + assert.isUndefined(controller.refs.gitTab.refs.commitView); await controller.getLastModelDataRefreshPromise(); assert.equal(controller.getActiveRepository(), repository); - await assert.async.isUndefined(controller.refs.gitPanel.refs.repoLoadingMessage); - await assert.async.isDefined(controller.refs.gitPanel.refs.stagingView); - await assert.async.isDefined(controller.refs.gitPanel.refs.commitViewController); + await assert.async.isUndefined(controller.refs.gitTab.refs.repoLoadingMessage); + await assert.async.isDefined(controller.refs.gitTab.refs.stagingView); + await assert.async.isDefined(controller.refs.gitTab.refs.commitViewController); }); it('keeps the state of the GitTabView in sync with the assigned repository', async function() { @@ -68,47 +68,47 @@ describe('GitTabController', function() { }); // Renders empty GitTabView when there is no active repository - assert.isDefined(controller.refs.gitPanel); + assert.isDefined(controller.refs.gitTab); assert.isTrue(controller.getActiveRepository().isAbsent()); - assert.isDefined(controller.refs.gitPanel.refs.noRepoMessage); + assert.isDefined(controller.refs.gitTab.refs.noRepoMessage); // Fetches data when a new repository is assigned // Does not update repository instance variable until that data is fetched await controller.update({repository: repository1}); assert.equal(controller.getActiveRepository(), repository1); - assert.deepEqual(controller.refs.gitPanel.props.unstagedChanges, await repository1.getUnstagedChanges()); + assert.deepEqual(controller.refs.gitTab.props.unstagedChanges, await repository1.getUnstagedChanges()); await controller.update({repository: repository2}); assert.equal(controller.getActiveRepository(), repository2); - assert.deepEqual(controller.refs.gitPanel.props.unstagedChanges, await repository2.getUnstagedChanges()); + assert.deepEqual(controller.refs.gitTab.props.unstagedChanges, await repository2.getUnstagedChanges()); // Fetches data and updates child view when the repository is mutated fs.writeFileSync(path.join(workdirPath2, 'a.txt'), 'a change\n'); fs.unlinkSync(path.join(workdirPath2, 'b.txt')); repository2.refresh(); await controller.getLastModelDataRefreshPromise(); - await assert.async.deepEqual(controller.refs.gitPanel.props.unstagedChanges, await repository2.getUnstagedChanges()); + await assert.async.deepEqual(controller.refs.gitTab.props.unstagedChanges, await repository2.getUnstagedChanges()); }); it('displays the staged changes since the parent commit when amending', async function() { const didChangeAmending = sinon.spy(); const workdirPath = await cloneRepository('multiple-commits'); const repository = await buildRepository(workdirPath); - const ensureGitPanel = () => Promise.resolve(false); + const ensureGitTab = () => Promise.resolve(false); const controller = new GitTabController({ - workspace, commandRegistry, repository, didChangeAmending, ensureGitPanel, + workspace, commandRegistry, repository, didChangeAmending, ensureGitTab, resolutionProgress, refreshResolutionProgress, isAmending: false, }); await controller.getLastModelDataRefreshPromise(); - await assert.async.deepEqual(controller.refs.gitPanel.props.stagedChanges, []); + await assert.async.deepEqual(controller.refs.gitTab.props.stagedChanges, []); assert.equal(didChangeAmending.callCount, 0); await controller.setAmending(true); assert.equal(didChangeAmending.callCount, 1); await controller.update({isAmending: true}); assert.deepEqual( - controller.refs.gitPanel.props.stagedChanges, + controller.refs.gitTab.props.stagedChanges, await controller.getActiveRepository().getStagedChangesSinceParentCommit(), ); @@ -186,9 +186,9 @@ describe('GitTabController', function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); - const ensureGitPanel = () => Promise.resolve(true); + const ensureGitTab = () => Promise.resolve(true); const controller = new GitTabController({ - workspace, commandRegistry, repository, ensureGitPanel, + workspace, commandRegistry, repository, ensureGitTab, resolutionProgress, refreshResolutionProgress, }); @@ -199,9 +199,9 @@ describe('GitTabController', function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); - const ensureGitPanel = () => Promise.resolve(false); + const ensureGitTab = () => Promise.resolve(false); const controller = new GitTabController({ - workspace, commandRegistry, repository, ensureGitPanel, + workspace, commandRegistry, repository, ensureGitTab, resolutionProgress, refreshResolutionProgress, }); @@ -258,8 +258,8 @@ describe('GitTabController', function() { await controller.getLastModelDataRefreshPromise(); await etch.getScheduler().getNextUpdatePromise(); - const gitPanel = controller.refs.gitPanel; - const stagingView = gitPanel.refs.stagingView; + const gitTab = controller.refs.gitTab; + const stagingView = gitTab.refs.stagingView; sinon.spy(stagingView, 'focus'); @@ -273,12 +273,12 @@ describe('GitTabController', function() { }); describe('keyboard navigation commands', function() { - let controller, gitPanel, stagingView, commitView, commitViewController, focusElement; + let controller, gitTab, stagingView, commitView, commitViewController, focusElement; const extractReferences = () => { - gitPanel = controller.refs.gitPanel; - stagingView = gitPanel.refs.stagingView; - commitViewController = gitPanel.refs.commitViewController; + gitTab = controller.refs.gitTab; + stagingView = gitTab.refs.stagingView; + commitViewController = gitTab.refs.commitViewController; commitView = commitViewController.refs.commitView; focusElement = stagingView; @@ -381,10 +381,10 @@ describe('GitTabController', function() { const didChangeAmending = () => {}; const prepareToCommit = () => Promise.resolve(true); - const ensureGitPanel = () => Promise.resolve(false); + const ensureGitTab = () => Promise.resolve(false); controller = new GitTabController({ - workspace, commandRegistry, repository, didChangeAmending, prepareToCommit, ensureGitPanel, + workspace, commandRegistry, repository, didChangeAmending, prepareToCommit, ensureGitTab, resolutionProgress, refreshResolutionProgress, }); await controller.getLastModelDataRefreshPromise(); @@ -422,15 +422,15 @@ describe('GitTabController', function() { const repository = await buildRepository(workdirPath); fs.writeFileSync(path.join(workdirPath, 'a.txt'), 'a change\n'); fs.unlinkSync(path.join(workdirPath, 'b.txt')); - const ensureGitPanel = () => Promise.resolve(false); + const ensureGitTab = () => Promise.resolve(false); const controller = new GitTabController({ - workspace, commandRegistry, repository, ensureGitPanel, didChangeAmending: sinon.stub(), + workspace, commandRegistry, repository, ensureGitTab, didChangeAmending: sinon.stub(), resolutionProgress, refreshResolutionProgress, }); await controller.getLastModelDataRefreshPromise(); await etch.getScheduler().getNextUpdatePromise(); - const stagingView = controller.refs.gitPanel.refs.stagingView; - const commitView = controller.refs.gitPanel.refs.commitViewController.refs.commitView; + const stagingView = controller.refs.gitTab.refs.stagingView; + const commitView = controller.refs.gitTab.refs.commitViewController.refs.commitView; assert.lengthOf(stagingView.props.unstagedChanges, 2); assert.lengthOf(stagingView.props.stagedChanges, 0); @@ -466,8 +466,8 @@ describe('GitTabController', function() { workspace, commandRegistry, repository, resolutionProgress, refreshResolutionProgress, }); - await assert.async.isDefined(controller.refs.gitPanel.refs.stagingView); - const stagingView = controller.refs.gitPanel.refs.stagingView; + await assert.async.isDefined(controller.refs.gitTab.refs.stagingView); + const stagingView = controller.refs.gitTab.refs.stagingView; assert.equal(stagingView.props.mergeConflicts.length, 5); assert.equal(stagingView.props.stagedChanges.length, 0); @@ -517,8 +517,8 @@ describe('GitTabController', function() { workspace, commandRegistry, repository, resolutionProgress, refreshResolutionProgress, }); - await assert.async.isDefined(controller.refs.gitPanel.refs.stagingView); - const stagingView = controller.refs.gitPanel.refs.stagingView; + await assert.async.isDefined(controller.refs.gitTab.refs.stagingView); + const stagingView = controller.refs.gitTab.refs.stagingView; assert.lengthOf(stagingView.props.unstagedChanges, 2); @@ -549,7 +549,7 @@ describe('GitTabController', function() { }); await controller.getLastModelDataRefreshPromise(); await etch.getScheduler().getNextUpdatePromise(); - const stagingView = controller.refs.gitPanel.refs.stagingView; + const stagingView = controller.refs.gitTab.refs.stagingView; const [addedFilePatch] = stagingView.props.unstagedChanges; assert.equal(addedFilePatch.filePath, 'new-file.txt'); diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index 22fcd063cf..7c1d87b8d5 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -276,7 +276,7 @@ describe('RootController', function() { }); }); - describe('when the StatusBarTileController calls toggleGitPanel', function() { + describe('when the StatusBarTileController calls toggleGitTab', function() { it('toggles the git panel', async function() { const workdirPath = await cloneRepository('multiple-commits'); const repository = await buildRepository(workdirPath); @@ -285,14 +285,14 @@ describe('RootController', function() { const wrapper = shallow(app); assert.isFalse(wrapper.find('Panel').prop('visible')); - wrapper.find('ObserveModelDecorator(StatusBarTileController)').prop('toggleGitPanel')(); + wrapper.find('ObserveModelDecorator(StatusBarTileController)').prop('toggleGitTab')(); assert.isTrue(wrapper.find('Panel').prop('visible')); - wrapper.find('ObserveModelDecorator(StatusBarTileController)').prop('toggleGitPanel')(); + wrapper.find('ObserveModelDecorator(StatusBarTileController)').prop('toggleGitTab')(); assert.isFalse(wrapper.find('Panel').prop('visible')); }); }); - describe('toggleGitPanel()', function() { + describe('toggleGitTab()', function() { it('toggles the visibility of the Git panel', async function() { const workdirPath = await cloneRepository('multiple-commits'); const repository = await buildRepository(workdirPath); @@ -301,14 +301,14 @@ describe('RootController', function() { const wrapper = shallow(app); assert.isFalse(wrapper.find('Panel').prop('visible')); - wrapper.instance().toggleGitPanel(); + wrapper.instance().toggleGitTab(); assert.isTrue(wrapper.find('Panel').prop('visible')); - wrapper.instance().toggleGitPanel(); + wrapper.instance().toggleGitTab(); assert.isFalse(wrapper.find('Panel').prop('visible')); }); }); - describe('toggleGitPanelFocus()', function() { + describe('toggleGitTabFocus()', function() { let wrapper; beforeEach(async function() { @@ -318,44 +318,44 @@ describe('RootController', function() { app = React.cloneElement(app, {repository}); wrapper = shallow(app); - sinon.stub(wrapper.instance(), 'focusGitPanel'); + sinon.stub(wrapper.instance(), 'focusGitTab'); sinon.spy(workspace.getActivePane(), 'activate'); }); it('opens and focuses the Git panel when it is initially closed', function() { assert.isFalse(wrapper.find('Panel').prop('visible')); - sinon.stub(wrapper.instance(), 'gitPanelHasFocus').returns(false); + sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(false); - wrapper.instance().toggleGitPanelFocus(); + wrapper.instance().toggleGitTabFocus(); assert.isTrue(wrapper.find('Panel').prop('visible')); - assert.equal(wrapper.instance().focusGitPanel.callCount, 1); + assert.equal(wrapper.instance().focusGitTab.callCount, 1); assert.isFalse(workspace.getActivePane().activate.called); }); it('focuses the Git panel when it is already open, but blurred', function() { - wrapper.instance().toggleGitPanel(); - sinon.stub(wrapper.instance(), 'gitPanelHasFocus').returns(false); + wrapper.instance().toggleGitTab(); + sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(false); assert.isTrue(wrapper.find('Panel').prop('visible')); - wrapper.instance().toggleGitPanelFocus(); + wrapper.instance().toggleGitTabFocus(); assert.isTrue(wrapper.find('Panel').prop('visible')); - assert.equal(wrapper.instance().focusGitPanel.callCount, 1); + assert.equal(wrapper.instance().focusGitTab.callCount, 1); assert.isFalse(workspace.getActivePane().activate.called); }); it('blurs the Git panel when it is already open and focused', function() { - wrapper.instance().toggleGitPanel(); - sinon.stub(wrapper.instance(), 'gitPanelHasFocus').returns(true); + wrapper.instance().toggleGitTab(); + sinon.stub(wrapper.instance(), 'gitTabHasFocus').returns(true); assert.isTrue(wrapper.find('Panel').prop('visible')); - wrapper.instance().toggleGitPanelFocus(); + wrapper.instance().toggleGitTabFocus(); assert.isTrue(wrapper.find('Panel').prop('visible')); - assert.equal(wrapper.instance().focusGitPanel.callCount, 0); + assert.equal(wrapper.instance().focusGitTab.callCount, 0); assert.isTrue(workspace.getActivePane().activate.called); }); }); @@ -479,7 +479,7 @@ describe('RootController', function() { }); }); - describe('ensureGitPanel()', function() { + describe('ensureGitTab()', function() { let wrapper; beforeEach(async function() { @@ -492,13 +492,13 @@ describe('RootController', function() { it('opens the Git panel when it is initially closed', async function() { assert.isFalse(wrapper.find('Panel').prop('visible')); - assert.isTrue(await wrapper.instance().ensureGitPanel()); + assert.isTrue(await wrapper.instance().ensureGitTab()); }); it('does nothing when the Git panel is already open', async function() { - wrapper.instance().toggleGitPanel(); + wrapper.instance().toggleGitTab(); assert.isTrue(wrapper.find('Panel').prop('visible')); - assert.isFalse(await wrapper.instance().ensureGitPanel()); + assert.isFalse(await wrapper.instance().ensureGitTab()); assert.isTrue(wrapper.find('Panel').prop('visible')); }); }); diff --git a/test/controllers/status-bar-tile-controller.test.js b/test/controllers/status-bar-tile-controller.test.js index 4a82f768b4..2895bd9767 100644 --- a/test/controllers/status-bar-tile-controller.test.js +++ b/test/controllers/status-bar-tile-controller.test.js @@ -465,9 +465,9 @@ describe('StatusBarTileController', function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); - const toggleGitPanel = sinon.spy(); + const toggleGitTab = sinon.spy(); - const wrapper = mount(React.cloneElement(component, {repository, toggleGitPanel})); + const wrapper = mount(React.cloneElement(component, {repository, toggleGitTab})); await wrapper.instance().refreshModelData(); assert.equal(wrapper.find('.github-ChangedFilesCount').render().text(), '0 files'); @@ -485,13 +485,13 @@ describe('StatusBarTileController', function() { const workdirPath = await cloneRepository('three-files'); const repository = await buildRepository(workdirPath); - const toggleGitPanel = sinon.spy(); + const toggleGitTab = sinon.spy(); - const wrapper = mount(React.cloneElement(component, {repository, toggleGitPanel})); + const wrapper = mount(React.cloneElement(component, {repository, toggleGitTab})); await wrapper.instance().refreshModelData(); wrapper.find(ChangedFilesCountView).simulate('click'); - assert(toggleGitPanel.calledOnce); + assert(toggleGitTab.calledOnce); }); }); }); From 26b9b5bd0200e5d907ca4acf564e146f0ae67470 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 15:39:05 -0400 Subject: [PATCH 119/161] :fire: unused imports --- lib/controllers/github-tab-controller.js | 3 +-- lib/views/push-pull-menu-view.js | 1 - test/models/workdir-context-pool.test.js | 3 +-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/controllers/github-tab-controller.js b/lib/controllers/github-tab-controller.js index fbb83bbf76..eff2da6f35 100644 --- a/lib/controllers/github-tab-controller.js +++ b/lib/controllers/github-tab-controller.js @@ -5,9 +5,8 @@ import yubikiri from 'yubikiri'; import RemotePrController from './remote-pr-controller'; import GithubLoginModel from '../models/github-login-model'; -import {nullBranch} from '../models/branch'; import ObserveModel from '../views/observe-model'; -import {RemotePropType, BranchPropType} from '../prop-types'; +import {RemotePropType} from '../prop-types'; class RemoteSelector extends React.Component { static propTypes = { diff --git a/lib/views/push-pull-menu-view.js b/lib/views/push-pull-menu-view.js index a67fefad0a..79430e887c 100644 --- a/lib/views/push-pull-menu-view.js +++ b/lib/views/push-pull-menu-view.js @@ -4,7 +4,6 @@ import {autobind} from 'core-decorators'; import {GitError} from '../git-shell-out-strategy'; import {BranchPropType, RemotePropType} from '../prop-types'; -import {nullBranch} from '../models/branch'; export default class PushPullMenuView extends React.Component { static propTypes = { diff --git a/test/models/workdir-context-pool.test.js b/test/models/workdir-context-pool.test.js index 783feef386..90278b8cda 100644 --- a/test/models/workdir-context-pool.test.js +++ b/test/models/workdir-context-pool.test.js @@ -1,8 +1,7 @@ import temp from 'temp'; -import {cloneRepository, buildRepository, sha} from '../helpers'; +import {cloneRepository, sha} from '../helpers'; -import Repository from '../../lib/models/repository'; import WorkdirContextPool from '../../lib/models/workdir-context-pool'; describe('WorkdirContextPool', function() { From 4583a1819e9b717bf8e3a803afd94d0075577fff Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 16:27:41 -0400 Subject: [PATCH 120/161] Default Repository data to null objects --- lib/controllers/git-tab-controller.js | 22 +++++++++++++++++-- test/controllers/git-tab-controller.test.js | 24 +++++++++++---------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/lib/controllers/git-tab-controller.js b/lib/controllers/git-tab-controller.js index 53402d81ab..6f043a970a 100644 --- a/lib/controllers/git-tab-controller.js +++ b/lib/controllers/git-tab-controller.js @@ -6,6 +6,8 @@ import etch from 'etch'; import GitTabView from '../views/git-tab-view'; import ModelObserver from '../models/model-observer'; +import {nullBranch} from '../models/branch'; +import {nullCommit} from '../models/commit'; import {autobind} from 'core-decorators'; import yubikiri from 'yubikiri'; @@ -18,7 +20,7 @@ export default class GitTabController { fetchData: this.fetchRepositoryData, didUpdate: () => { this.refreshResolutionProgress(false, false); - return etch.update(this); + return this.update(); }, }); this.repositoryObserver.setActiveModel(props.repository); @@ -32,7 +34,7 @@ export default class GitTabController { } render() { - const modelData = this.repositoryObserver.getActiveModelData() || {fetchInProgress: true}; + const modelData = this.repositoryObserver.getActiveModelData() || this.defaultRepositoryData(); const hasUndoHistory = this.props.repository ? this.hasUndoHistory() : false; return ( Date: Tue, 18 Apr 2017 16:27:58 -0400 Subject: [PATCH 121/161] Always emit an update event on state change --- lib/models/repository.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/models/repository.js b/lib/models/repository.js index 1058d1a8cf..b5ca204e55 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -62,6 +62,7 @@ export default class Repository { this.state = nextState; this.emitter.emit('did-change-state', {from: currentState, to: this.state}); + this.emitter.emit('did-update'); return this.state.start(); } From bd9b66ec6562e0833cc4fdbea8dd99659cda046c Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 16:28:57 -0400 Subject: [PATCH 122/161] Hide the Amend checkbox while loading --- lib/views/commit-view.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/views/commit-view.js b/lib/views/commit-view.js index dd91bab111..1130c14b08 100644 --- a/lib/views/commit-view.js +++ b/lib/views/commit-view.js @@ -50,7 +50,11 @@ export default class CommitView { } const showAbortMergeButton = this.props.isMerging || null; - const showAmendBox = (!this.props.isMerging && !this.props.lastCommit.isUnbornRef()) || null; + const showAmendBox = ( + !this.props.isMerging && + this.props.lastCommit.isPresent() && + !this.props.lastCommit.isUnbornRef() + ) || null; return (
    From 863d5208a4f4c4512e4f9f2e8149e5de3c96f679 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 16:31:26 -0400 Subject: [PATCH 123/161] Test the CommitView in the loading state --- lib/views/commit-view.js | 1 + test/views/commit-view.test.js | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/views/commit-view.js b/lib/views/commit-view.js index 1130c14b08..70591951d5 100644 --- a/lib/views/commit-view.js +++ b/lib/views/commit-view.js @@ -131,6 +131,7 @@ export default class CommitView { isCommitButtonEnabled() { return this.props.stagedChangesExist && !this.props.mergeConflictsExist && + this.props.lastCommit.isPresent() && this.editor && this.editor.getText().length !== 0; } diff --git a/test/views/commit-view.test.js b/test/views/commit-view.test.js index 3ad0253eac..c5673f3c6c 100644 --- a/test/views/commit-view.test.js +++ b/test/views/commit-view.test.js @@ -2,7 +2,7 @@ import {cloneRepository, buildRepository} from '../helpers'; import etch from 'etch'; import until from 'test-until'; -import Commit from '../../lib/models/commit'; +import Commit, {nullCommit} from '../../lib/models/commit'; import CommitView from '../../lib/views/commit-view'; describe('CommitView', function() { @@ -19,6 +19,28 @@ describe('CommitView', function() { atomEnv.destroy(); }); + describe('when the repo is loading', function() { + let view; + + beforeEach(function() { + view = new CommitView({ + commandRegistry, lastCommit: nullCommit, + stagedChangesExist: false, maximumCharacterLimit: 72, message: '', + }); + }); + + it("doesn't show the amend checkbox", function() { + assert.isUndefined(view.refs.amend); + }); + + it('disables the commit button', async function() { + view.refs.editor.setText('even with text'); + await view.update({}); + + assert.isTrue(view.refs.commitButton.disabled); + }); + }); + it('displays the remaining characters limit based on which line is being edited', async function() { const view = new CommitView({ commandRegistry, lastCommit, From b25433beb9ead8c91038677b6367c2b37edee551 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 16:31:41 -0400 Subject: [PATCH 124/161] Apply .is-loading to the panel when loading --- lib/views/git-tab-view.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js index 7155fa0922..b87a1a4848 100644 --- a/lib/views/git-tab-view.js +++ b/lib/views/git-tab-view.js @@ -5,6 +5,7 @@ import {Disposable, CompositeDisposable} from 'event-kit'; import etch from 'etch'; import {autobind} from 'core-decorators'; +import cx from 'classnames'; import StagingView from './staging-view'; import GitLogo from './git-logo'; @@ -36,7 +37,7 @@ export default class GitTabView { render() { if (this.props.repository.isEmpty()) { return ( -
    +
    @@ -48,15 +49,11 @@ export default class GitTabView {
    ); - } else if (this.props.fetchInProgress) { - return ( -
    -
    Fetching repository data
    -
    - ); } else { + const isLoading = this.props.fetchInProgress || this.props.repository.isLoading(); + return ( -
    +
    From 8e8f3dc8a6db46fe71ca271a589ff62008a5cd62 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Tue, 18 Apr 2017 16:31:59 -0400 Subject: [PATCH 125/161] Grey out controls and text while loading --- styles/git-panel.less | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/styles/git-panel.less b/styles/git-panel.less index a2e8947b0f..bc77be7457 100644 --- a/styles/git-panel.less +++ b/styles/git-panel.less @@ -22,13 +22,12 @@ -webkit-user-select: none; cursor: default; - &.is-loading, &.no-repository { + &.no-repository { padding: @component-padding * 3; text-align: center; } &.is-loading { - font-style: italic; color: @text-color-subtle; } From 94acec7fbb76b271f9404d5535ab3c132176dcaf Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 09:43:52 -0400 Subject: [PATCH 126/161] Undetermined state for package activation delay --- lib/models/repository-states/index.js | 3 ++- lib/models/repository-states/undetermined.js | 14 +++++++++++ lib/models/repository.js | 24 +++++++++++++++--- lib/models/workdir-context.js | 26 ++++++++++++-------- test/models/workdir-context.test.js | 11 ++++++++- 5 files changed, 62 insertions(+), 16 deletions(-) create mode 100644 lib/models/repository-states/undetermined.js diff --git a/lib/models/repository-states/index.js b/lib/models/repository-states/index.js index 954b020473..e4ed4ded2c 100644 --- a/lib/models/repository-states/index.js +++ b/lib/models/repository-states/index.js @@ -1,6 +1,7 @@ export {expectedDelegates} from './state'; -export {default as InitialState} from './loading'; +export {default as LoadingState} from './loading'; +export {default as UndeterminedState} from './undetermined'; export {default as AbsentState} from './absent'; // Load and register remaining states diff --git a/lib/models/repository-states/undetermined.js b/lib/models/repository-states/undetermined.js new file mode 100644 index 0000000000..d06731c10d --- /dev/null +++ b/lib/models/repository-states/undetermined.js @@ -0,0 +1,14 @@ +import State from './state'; + +export default class Undetermined extends State { + start() { + setTimeout(() => this.transitionTo('Absent'), 1000); + return Promise.resolve(); + } + + isLoading() { + return true; + } +} + +State.register(Undetermined); diff --git a/lib/models/repository.js b/lib/models/repository.js index b5ca204e55..6da5964d0e 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -5,11 +5,13 @@ import {Emitter} from 'event-kit'; import CompositeGitStrategy from '../composite-git-strategy'; import {readFile} from '../helpers'; import Remote, {nullRemote} from './remote'; -import {InitialState, AbsentState} from './repository-states'; +import {LoadingState, AbsentState, UndeterminedState} from './repository-states'; const MERGE_MARKER_REGEX = /^(>|<){7} \S+$/m; const ABSENT = {}; +const UNDETERMINED = {}; +const LOADING = {}; export class AbortMergeError extends Error { constructor(code, filePath) { @@ -30,8 +32,18 @@ export class CommitError extends Error { } } +function getStateConstructor(token) { + if (token === ABSENT) { + return AbsentState; + } else if (token === UNDETERMINED) { + return UndeterminedState; + } else { + return LoadingState; + } +} + export default class Repository { - constructor(workingDirectoryPath, gitStrategy = null) { + constructor(workingDirectoryPath, gitStrategy = null, initialState = LOADING) { this.workingDirectoryPath = workingDirectoryPath; this.git = gitStrategy || CompositeGitStrategy.create(workingDirectoryPath); @@ -48,11 +60,15 @@ export default class Repository { }); }); - this.transitionTo(workingDirectoryPath !== ABSENT ? InitialState : AbsentState); + this.transitionTo(getStateConstructor(initialState)); } static absent() { - return new Repository(ABSENT); + return new Repository(null, null, ABSENT); + } + + static undetermined() { + return new Repository(null, null, UNDETERMINED); } // State management ////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index 379d95e2e0..c4d1c1a1c9 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -6,7 +6,10 @@ import ResolutionProgress from './conflicts/resolution-progress'; import FileSystemChangeObserver from './file-system-change-observer'; import WorkspaceChangeObserver from './workspace-change-observer'; +const initRepoState = Symbol('initRepoState'); + const ABSENT = {}; +const UNDETERMINED = {}; /* * Bundle of model objects associated with a git working directory. @@ -26,13 +29,12 @@ export default class WorkdirContext { * - `options.promptCallback`: Callback used to collect information interactively through Atom. */ constructor(directory, options = {}) { - if (directory !== ABSENT) { - this.directory = directory; - this.repository = new Repository(directory); - } else { - this.directory = null; - this.repository = Repository.absent(); - } + this.directory = directory; + this.repository = (token => { + if (token === ABSENT) { return Repository.absent(); } + if (token === UNDETERMINED) { return Repository.undetermined(); } + return new Repository(directory); + })(options[initRepoState]); const {window: theWindow, workspace, promptCallback} = options; this.savedResolutionProgress = options.savedResolutionProgress || {}; @@ -59,6 +61,10 @@ export default class WorkdirContext { this.repositoryChangedState({from: null, to: this.repository.state}); } + static undetermined() { + return new WorkdirContext(null, {[initRepoState]: UNDETERMINED}); + } + /** * Respond to changes in `Repository` state. Load resolution progress and start the change observer when it becomes * present. Stop the change observer when it is destroyed. Re-broadcast the event to context subscribers @@ -178,9 +184,9 @@ export default class WorkdirContext { } } -class NullWorkdirContext extends WorkdirContext { +class AbsentWorkdirContext extends WorkdirContext { constructor() { - super(ABSENT); + super(null, {[initRepoState]: ABSENT}); } isPresent() { @@ -188,4 +194,4 @@ class NullWorkdirContext extends WorkdirContext { } } -export const nullWorkdirContext = new NullWorkdirContext(); +export const absentWorkdirContext = new AbsentWorkdirContext(); diff --git a/test/models/workdir-context.test.js b/test/models/workdir-context.test.js index 99a37114d4..9bc1858150 100644 --- a/test/models/workdir-context.test.js +++ b/test/models/workdir-context.test.js @@ -2,7 +2,7 @@ import {CompositeDisposable, Disposable} from 'event-kit'; import {cloneRepository, sha} from '../helpers'; -import WorkdirContext from '../../lib/models/workdir-context'; +import WorkdirContext, {absentWorkdirContext} from '../../lib/models/workdir-context'; describe('WorkdirContext', function() { let context, workingDirectory, subs; @@ -122,4 +122,13 @@ describe('WorkdirContext', function() { await context.destroy(); assert.isTrue(context.isDestroyed()); }); + + it('exports a singleton containing a Repository in the absent state', function() { + assert.isTrue(absentWorkdirContext.getRepository().isAbsent()); + }); + + it('can be constructed containing a Repository in an undetermined state', function() { + const undetermined = WorkdirContext.undetermined(); + assert.isTrue(undetermined.getRepository().isLoading()); + }); }); From c22b6d1a4cab2d76abe8433b325623260db120cf Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 09:53:26 -0400 Subject: [PATCH 127/161] Give Undetermined its own state probe method --- lib/models/repository-states/state.js | 11 ++++++++--- lib/models/repository-states/undetermined.js | 2 +- lib/models/repository.js | 3 ++- test/models/workdir-context.test.js | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index 9330ae0416..fae51ea18b 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -39,17 +39,22 @@ export default class State { // Allow external callers to identify which state a Repository is in if necessary. @shouldDelegate - isLoading() { + isUndetermined() { return false; } @shouldDelegate - isEmpty() { + isAbsent() { return false; } @shouldDelegate - isAbsent() { + isLoading() { + return false; + } + + @shouldDelegate + isEmpty() { return false; } diff --git a/lib/models/repository-states/undetermined.js b/lib/models/repository-states/undetermined.js index d06731c10d..96ebd38bce 100644 --- a/lib/models/repository-states/undetermined.js +++ b/lib/models/repository-states/undetermined.js @@ -6,7 +6,7 @@ export default class Undetermined extends State { return Promise.resolve(); } - isLoading() { + isUndetermined() { return true; } } diff --git a/lib/models/repository.js b/lib/models/repository.js index 6da5964d0e..2daa560db1 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -186,9 +186,10 @@ export default class Repository { // This list should match the methods decorated with @shouldDelegate in `lib/models/repository-states/state.js`. A test // case in `test/models/repository.test.js` ensures that these sets match. const delegates = [ + 'isUndetermined', + 'isAbsent', 'isLoading', 'isEmpty', - 'isAbsent', 'isPresent', 'isDestroyed', diff --git a/test/models/workdir-context.test.js b/test/models/workdir-context.test.js index 9bc1858150..47edba2829 100644 --- a/test/models/workdir-context.test.js +++ b/test/models/workdir-context.test.js @@ -129,6 +129,6 @@ describe('WorkdirContext', function() { it('can be constructed containing a Repository in an undetermined state', function() { const undetermined = WorkdirContext.undetermined(); - assert.isTrue(undetermined.getRepository().isLoading()); + assert.isTrue(undetermined.getRepository().isUndetermined()); }); }); From 6c05e1aae5651f42c0f68c5221f1d24f9e9d50d5 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 09:59:15 -0400 Subject: [PATCH 128/161] Return absentWorkdirContexts from the pool --- lib/models/workdir-context-pool.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/models/workdir-context-pool.js b/lib/models/workdir-context-pool.js index c5db10fac9..278e532722 100644 --- a/lib/models/workdir-context-pool.js +++ b/lib/models/workdir-context-pool.js @@ -1,7 +1,7 @@ import compareSets from 'compare-sets'; import {Emitter, CompositeDisposable} from 'event-kit'; -import WorkdirContext, {nullWorkdirContext} from './workdir-context'; +import WorkdirContext, {absentWorkdirContext} from './workdir-context'; /** * Manage a WorkdirContext for each open directory. @@ -26,7 +26,7 @@ export default class WorkdirContextPool { * Access the context mapped to a known directory. */ getContext(directory) { - return this.contexts.get(directory) || nullWorkdirContext; + return this.contexts.get(directory) || absentWorkdirContext; } add(directory, options = {}) { From adf8d52d96ce0e964ba214a97e92e13116c08644 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 09:59:36 -0400 Subject: [PATCH 129/161] Begin with an Undetermined context instead of an Absent one --- lib/github-package.js | 6 +++--- test/github-package.test.js | 9 ++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index f7831be71d..6a5c593a7c 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -7,7 +7,7 @@ import ReactDom from 'react-dom'; import {autobind} from 'core-decorators'; import WorkdirCache from './models/workdir-cache'; -import {nullWorkdirContext} from './models/workdir-context'; +import WorkdirContext, {absentWorkdirContext} from './models/workdir-context'; import WorkdirContextPool from './models/workdir-context-pool'; import Repository from './models/repository'; import StyleCalculator from './models/style-calculator'; @@ -38,7 +38,7 @@ export default class GithubPackage { this.useLegacyPanels = false; this.activeContextQueue = new AsyncQueue(); - this.activeContext = nullWorkdirContext; + this.activeContext = WorkdirContext.undetermined(); this.workdirCache = new WorkdirCache(); this.contextPool = new WorkdirContextPool({ window, @@ -58,7 +58,7 @@ export default class GithubPackage { }), this.contextPool.onDidDestroyRepository(context => { if (context === this.activeContext) { - this.setActiveContext(nullWorkdirContext); + this.setActiveContext(absentWorkdirContext); } }), ); diff --git a/test/github-package.test.js b/test/github-package.test.js index bec0ddbe82..8aac028f18 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -40,6 +40,13 @@ describe('GithubPackage', function() { } describe('activate()', function() { + it('begins with an undetermined repository context', async function() { + await githubPackage.activate(); + await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); + + assert.isTrue(githubPackage.getActiveRepository().isUndetermined()); + }); + it('uses models from preexisting projects', async function() { const [workdirPath1, workdirPath2, nonRepositoryPath] = await Promise.all([ cloneRepository('three-files'), @@ -55,7 +62,7 @@ describe('GithubPackage', function() { assert.isTrue(contextPool.getContext(workdirPath2).isPresent()); assert.isTrue(contextPool.getContext(nonRepositoryPath).isPresent()); - assert.isTrue(githubPackage.getActiveRepository().isAbsent()); + assert.isTrue(githubPackage.getActiveRepository().isUndetermined()); }); it('uses an active model from a single preexisting project', async function() { From 950c14dafad27dd929bd46ec778af1af7acff542 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 10:23:46 -0400 Subject: [PATCH 130/161] Flatten the State hierarchy --- lib/models/repository-states/absent.js | 13 +------------ lib/models/repository-states/cloning.js | 6 ++---- lib/models/repository-states/initializing.js | 6 ++---- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/lib/models/repository-states/absent.js b/lib/models/repository-states/absent.js index 7f21806f4c..6d3e6914c6 100644 --- a/lib/models/repository-states/absent.js +++ b/lib/models/repository-states/absent.js @@ -1,22 +1,11 @@ import State from './state'; -import Empty from './empty'; -export default class Absent extends Empty { +export default class Absent extends State { isAbsent() { return true; } - init() { - return disallowed('init'); - } - - clone() { - return disallowed('clone'); } } State.register(Absent); - -function disallowed(actionName) { - return Promise.reject(new Error(`${actionName} cannot be performed on an absent repository`)); -} diff --git a/lib/models/repository-states/cloning.js b/lib/models/repository-states/cloning.js index c92d7af0f1..49c374e889 100644 --- a/lib/models/repository-states/cloning.js +++ b/lib/models/repository-states/cloning.js @@ -1,9 +1,8 @@ import {mkdirs} from '../../helpers'; import State from './state'; -import Loading from './loading'; -export default class Cloning extends Loading { +export default class Cloning extends State { constructor(repository, remoteUrl) { super(repository); this.remoteUrl = remoteUrl; @@ -13,8 +12,7 @@ export default class Cloning extends Loading { await mkdirs(this.workdir()); await this.git().clone(this.remoteUrl, {recursive: true}); - // See https://github.com/babel/babel/issues/3930 - await Loading.prototype.start.call(this); + await this.transitionTo('Loading'); } } diff --git a/lib/models/repository-states/initializing.js b/lib/models/repository-states/initializing.js index f5eb107167..8eddcc6c6e 100644 --- a/lib/models/repository-states/initializing.js +++ b/lib/models/repository-states/initializing.js @@ -1,12 +1,10 @@ import State from './state'; -import Loading from './loading'; -export default class Initializing extends Loading { +export default class Initializing extends State { async start() { await this.git().init(this.workdir()); - // See https://github.com/babel/babel/issues/3930 - await Loading.prototype.start.call(this); + await this.transitionTo('Loading'); } } From c388a4ca4dd3d161144e9da4857858e952a4f735 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 10:24:45 -0400 Subject: [PATCH 131/161] Default implementation of destroy() --- lib/models/repository-states/empty.js | 2 -- lib/models/repository-states/loading.js | 2 -- lib/models/repository-states/present.js | 4 ---- lib/models/repository-states/state.js | 2 +- 4 files changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/models/repository-states/empty.js b/lib/models/repository-states/empty.js index c3f4039dd6..0acf6c05d9 100644 --- a/lib/models/repository-states/empty.js +++ b/lib/models/repository-states/empty.js @@ -13,8 +13,6 @@ export default class Empty extends State { return this.transitionTo('Cloning', remoteUrl); } - destroy() { - this.transitionTo('Destroyed'); } } diff --git a/lib/models/repository-states/loading.js b/lib/models/repository-states/loading.js index 1b9ea13015..df0f113395 100644 --- a/lib/models/repository-states/loading.js +++ b/lib/models/repository-states/loading.js @@ -24,8 +24,6 @@ export default class Loading extends State { await this.repository.clone(remoteUrl); } - destroy() { - this.transitionTo('Destroyed'); } } diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index ade6925359..e9c02868f3 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -79,10 +79,6 @@ export default class Present extends State { return true; } - destroy() { - this.transitionTo('Destroyed'); - } - refresh() { this.cache.clear(); this.didUpdate(); diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index fae51ea18b..1d7aaf21b5 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -83,7 +83,7 @@ export default class State { @shouldDelegate destroy() { - return unsupportedOperation(this, 'destroy'); + return this.transitionTo('Destroyed'); } @shouldDelegate From 0cc01c32ab995f6040997bf0325c3df4f399e532 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 10:25:35 -0400 Subject: [PATCH 132/161] Use behavior probes instead of state probes --- lib/models/repository-states/absent.js | 2 ++ lib/models/repository-states/cloning.js | 4 ++++ lib/models/repository-states/empty.js | 2 ++ lib/models/repository-states/initializing.js | 4 ++++ lib/models/repository-states/loading.js | 2 ++ lib/models/repository-states/state.js | 18 ++++++++++++++++++ lib/models/repository-states/undetermined.js | 4 ++++ lib/models/repository.js | 4 ++++ lib/views/git-tab-view.js | 13 ++++++------- test/models/repository.test.js | 4 +--- 10 files changed, 47 insertions(+), 10 deletions(-) diff --git a/lib/models/repository-states/absent.js b/lib/models/repository-states/absent.js index 6d3e6914c6..3b5c3f8293 100644 --- a/lib/models/repository-states/absent.js +++ b/lib/models/repository-states/absent.js @@ -5,6 +5,8 @@ export default class Absent extends State { return true; } + showGitTabInit() { + return true; } } diff --git a/lib/models/repository-states/cloning.js b/lib/models/repository-states/cloning.js index 49c374e889..a1eb34223b 100644 --- a/lib/models/repository-states/cloning.js +++ b/lib/models/repository-states/cloning.js @@ -14,6 +14,10 @@ export default class Cloning extends State { await this.transitionTo('Loading'); } + + showGitTabLoading() { + return true; + } } State.register(Cloning); diff --git a/lib/models/repository-states/empty.js b/lib/models/repository-states/empty.js index 0acf6c05d9..720973a758 100644 --- a/lib/models/repository-states/empty.js +++ b/lib/models/repository-states/empty.js @@ -13,6 +13,8 @@ export default class Empty extends State { return this.transitionTo('Cloning', remoteUrl); } + showGitTabInit() { + return true; } } diff --git a/lib/models/repository-states/initializing.js b/lib/models/repository-states/initializing.js index 8eddcc6c6e..5d3fa60621 100644 --- a/lib/models/repository-states/initializing.js +++ b/lib/models/repository-states/initializing.js @@ -6,6 +6,10 @@ export default class Initializing extends State { await this.transitionTo('Loading'); } + + showGitTabLoading() { + return true; + } } State.register(Initializing); diff --git a/lib/models/repository-states/loading.js b/lib/models/repository-states/loading.js index df0f113395..dd9503faa5 100644 --- a/lib/models/repository-states/loading.js +++ b/lib/models/repository-states/loading.js @@ -24,6 +24,8 @@ export default class Loading extends State { await this.repository.clone(remoteUrl); } + showGitTabLoading() { + return true; } } diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index 1d7aaf21b5..ae38d4e6d8 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -68,6 +68,24 @@ export default class State { return false; } + // Behavior probe predicates ///////////////////////////////////////////////////////////////////////////////////////// + // Determine specific rendering behavior based on the current state. + + @shouldDelegate + showGitTabInit() { + return false; + } + + @shouldDelegate + showGitTabInitInProgress() { + return false; + } + + @shouldDelegate + showGitTabLoading() { + return false; + } + // Lifecycle actions ///////////////////////////////////////////////////////////////////////////////////////////////// // These generally default to rejecting a Promise with an error. diff --git a/lib/models/repository-states/undetermined.js b/lib/models/repository-states/undetermined.js index 96ebd38bce..c418ca9518 100644 --- a/lib/models/repository-states/undetermined.js +++ b/lib/models/repository-states/undetermined.js @@ -9,6 +9,10 @@ export default class Undetermined extends State { isUndetermined() { return true; } + + showGitTabLoading() { + return true; + } } State.register(Undetermined); diff --git a/lib/models/repository.js b/lib/models/repository.js index 2daa560db1..abd54fdab1 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -193,6 +193,10 @@ const delegates = [ 'isPresent', 'isDestroyed', + 'showGitTabInit', + 'showGitTabInitInProgress', + 'showGitTabLoading', + 'init', 'clone', 'destroy', diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js index b87a1a4848..14f479d5c0 100644 --- a/lib/views/git-tab-view.js +++ b/lib/views/git-tab-view.js @@ -15,7 +15,6 @@ export default class GitTabView { constructor(props) { this.props = props; etch.initialize(this); - this.repoCreationInProgress = false; this.element.addEventListener('focusin', this.rememberLastFocus); @@ -35,7 +34,9 @@ export default class GitTabView { } render() { - if (this.props.repository.isEmpty()) { + if (this.props.repository.showGitTabInit()) { + const inProgress = this.props.repository.showGitTabInitInProgress(); + return (
    @@ -43,14 +44,14 @@ export default class GitTabView {
    Initialize this project directory with a Git repository
    -
    ); } else { - const isLoading = this.props.fetchInProgress || this.props.repository.isLoading(); + const isLoading = this.props.fetchInProgress || this.props.repository.showGitTabLoading(); return (
    @@ -111,12 +112,10 @@ export default class GitTabView { @autobind async initializeRepo(event) { event.preventDefault(); - this.repoCreationInProgress = true; etch.update(this); try { await this.props.initializeRepo(); } finally { - this.repoCreationInProgress = false; etch.update(this); } } diff --git a/test/models/repository.test.js b/test/models/repository.test.js index cbbe505985..2a0528fc33 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -55,9 +55,7 @@ describe('Repository', function() { await repo.getLoadPromise(); assert.isTrue(repo.isEmpty()); - const initPromise = repo.init(); - assert.isTrue(repo.isLoading()); - await initPromise; + await repo.init(); assert.isTrue(repo.isPresent()); assert.equal(repo.getWorkingDirectoryPath(), soonToBeRepositoryPath); From 7369cacac714d53aabaa87c8ea7727d5cb59bcfe Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 10:25:42 -0400 Subject: [PATCH 133/161] toString() for debugging handiness --- lib/models/repository.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/models/repository.js b/lib/models/repository.js index abd54fdab1..bd26978b8d 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -147,6 +147,10 @@ export default class Repository { return this.state.constructor.name === stateName; } + toString() { + return `Repository(state=${this.state.constructor.name}, workdir="${this.getWorkingDirectoryPath()}")`; + } + // Compound Getters ////////////////////////////////////////////////////////////////////////////////////////////////// // Accessor methods for data derived from other, state-provided getters. From 719c15d715965fb530025388f761139f170ad30e Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 10:36:01 -0400 Subject: [PATCH 134/161] Embiggen Undetermined state timeout --- lib/models/repository-states/undetermined.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/models/repository-states/undetermined.js b/lib/models/repository-states/undetermined.js index c418ca9518..25f1572ba8 100644 --- a/lib/models/repository-states/undetermined.js +++ b/lib/models/repository-states/undetermined.js @@ -1,8 +1,10 @@ import State from './state'; +const UNDETERMINED_TIMEOUT = 2000; + export default class Undetermined extends State { start() { - setTimeout(() => this.transitionTo('Absent'), 1000); + setTimeout(() => this.transitionTo('Absent'), UNDETERMINED_TIMEOUT); return Promise.resolve(); } From ecefa93c5bec628070c603be03713293a094784a Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 12:48:37 -0400 Subject: [PATCH 135/161] Hide branch and push/pull status bar tiles unless a repo is present --- lib/controllers/status-bar-tile-controller.js | 22 ++++++++++++++----- lib/models/repository-states/present.js | 4 ++++ lib/models/repository-states/state.js | 5 +++++ lib/models/repository.js | 1 + .../status-bar-tile-controller.test.js | 18 +++++++++++++++ 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/lib/controllers/status-bar-tile-controller.js b/lib/controllers/status-bar-tile-controller.js index 9ba9e2e08b..c452e6339e 100644 --- a/lib/controllers/status-bar-tile-controller.js +++ b/lib/controllers/status-bar-tile-controller.js @@ -88,6 +88,22 @@ export default class StatusBarTileController extends React.Component { return (
    + {this.renderTiles(repoProps)} + +
    + ); + } + + renderTiles(repoProps) { + if (!this.props.repository.showStatusBarTiles()) { + return null; + } + + return ( + @@ -141,11 +157,7 @@ export default class StatusBarTileController extends React.Component { {...repoProps} /> - -
    + ); } diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index e9c02868f3..455f522a64 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -79,6 +79,10 @@ export default class Present extends State { return true; } + showStatusBarTiles() { + return true; + } + refresh() { this.cache.clear(); this.didUpdate(); diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index ae38d4e6d8..d950125c1f 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -86,6 +86,11 @@ export default class State { return false; } + @shouldDelegate + showStatusBarTiles() { + return false; + } + // Lifecycle actions ///////////////////////////////////////////////////////////////////////////////////////////////// // These generally default to rejecting a Promise with an error. diff --git a/lib/models/repository.js b/lib/models/repository.js index bd26978b8d..3164a7a606 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -200,6 +200,7 @@ const delegates = [ 'showGitTabInit', 'showGitTabInitInProgress', 'showGitTabLoading', + 'showStatusBarTiles', 'init', 'clone', diff --git a/test/controllers/status-bar-tile-controller.test.js b/test/controllers/status-bar-tile-controller.test.js index 2895bd9767..81b2a0631c 100644 --- a/test/controllers/status-bar-tile-controller.test.js +++ b/test/controllers/status-bar-tile-controller.test.js @@ -6,6 +6,8 @@ import until from 'test-until'; import {mount} from 'enzyme'; import {cloneRepository, buildRepository, setUpLocalAndRemoteRepositories} from '../helpers'; +import {getTempDir} from '../../lib/helpers'; +import Repository from '../../lib/models/repository'; import StatusBarTileController from '../../lib/controllers/status-bar-tile-controller'; import BranchView from '../../lib/views/branch-view'; import PushPullView from '../../lib/views/push-pull-view'; @@ -494,4 +496,20 @@ describe('StatusBarTileController', function() { assert(toggleGitTab.calledOnce); }); }); + + describe('while the repository is not present', function() { + it('does not display the branch or push-pull tiles', async function() { + const workdirPath = await getTempDir(); + const repository = new Repository(workdirPath); + assert.isFalse(repository.isPresent()); + + const wrapper = mount(React.cloneElement(component, {repository})); + + assert.isFalse(wrapper.find('BranchView').exists()); + assert.isFalse(wrapper.find('BranchMenuView').exists()); + assert.isFalse(wrapper.find('PushPullView').exists()); + assert.isFalse(wrapper.find('PushPullMenuView').exists()); + assert.isTrue(wrapper.find('ChangedFilesCountView').exists()); + }); + }); }); From 06408fa20942daff01cca6d954ea8f98ce277278 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 13:55:53 -0400 Subject: [PATCH 136/161] :fire: unnecessary etch.updates --- lib/views/git-tab-view.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js index 14f479d5c0..01ecd9034b 100644 --- a/lib/views/git-tab-view.js +++ b/lib/views/git-tab-view.js @@ -112,12 +112,7 @@ export default class GitTabView { @autobind async initializeRepo(event) { event.preventDefault(); - etch.update(this); - try { - await this.props.initializeRepo(); - } finally { - etch.update(this); - } + await this.props.initializeRepo(); } @autobind From be0ab107b5d64aa33de7e1d10132cdf28e096f67 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 15:13:49 -0400 Subject: [PATCH 137/161] Init dialog --- lib/views/init-dialog.js | 121 +++++++++++++++++++++++++++++++++ test/views/init-dialog.test.js | 68 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 lib/views/init-dialog.js create mode 100644 test/views/init-dialog.test.js diff --git a/lib/views/init-dialog.js b/lib/views/init-dialog.js new file mode 100644 index 0000000000..39a445c1d2 --- /dev/null +++ b/lib/views/init-dialog.js @@ -0,0 +1,121 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {autobind} from 'core-decorators'; +import {CompositeDisposable} from 'event-kit'; + +import Commands, {Command} from './commands'; + +export default class InitDialog extends React.Component { + static propTypes = { + config: PropTypes.object.isRequired, + commandRegistry: PropTypes.object.isRequired, + didAccept: PropTypes.func, + didCancel: PropTypes.func, + } + + static defaultProps = { + didAccept: () => {}, + didCancel: () => {}, + } + + constructor(props, context) { + super(props, context); + + this.state = { + initDisabled: false, + }; + + this.projectHome = this.props.config.get('core.projectHome'); + this.subs = new CompositeDisposable(); + } + + componentDidMount() { + if (this.projectPathEditor) { + this.projectPathEditor.setText(this.props.config.get('core.projectHome')); + this.projectPathModified = false; + } + + if (this.projectPathElement) { + setTimeout(() => this.projectPathElement.focus()); + } + } + + render() { + return ( +
    + + + + +
    + +
    +
    + + +
    +
    + ); + } + + @autobind + init() { + if (this.getProjectPath().length === 0) { + return; + } + + this.props.didAccept(this.getProjectPath()); + } + + @autobind + cancel() { + this.props.didCancel(); + } + + @autobind + editorRef() { + return element => { + if (!element) { + return; + } + + this.projectPathElement = element; + const editor = element.getModel(); + if (this.projectPathEditor !== editor) { + this.projectPathEditor = editor; + + if (this.projectPathSubs) { + this.projectPathSubs.dispose(); + this.subs.remove(this.projectPathSubs); + } + + this.projectPathSubs = editor.onDidChange(this.setInitEnablement); + this.subs.add(this.projectPathSubs); + } + }; + } + + getProjectPath() { + return this.projectPathEditor ? this.projectPathEditor.getText() : ''; + } + + getRemoteUrl() { + return this.remoteUrlEditor ? this.remoteUrlEditor.getText() : ''; + } + + @autobind + setInitEnablement() { + this.setState({initDisabled: this.getProjectPath().length === 0}); + } +} diff --git a/test/views/init-dialog.test.js b/test/views/init-dialog.test.js new file mode 100644 index 0000000000..3991558c49 --- /dev/null +++ b/test/views/init-dialog.test.js @@ -0,0 +1,68 @@ +import React from 'react'; +import {mount} from 'enzyme'; +import path from 'path'; + +import InitDialog from '../../lib/views/init-dialog'; + +describe('InitDialog', function() { + let atomEnv, config, commandRegistry; + let app, wrapper, didAccept, didCancel; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + config = atomEnv.config; + commandRegistry = atomEnv.commands; + sinon.stub(config, 'get').returns(path.join('home', 'me', 'codes')); + + didAccept = sinon.stub(); + didCancel = sinon.stub(); + + app = ( + + ); + wrapper = mount(app); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + const setTextIn = function(selector, text) { + wrapper.find(selector).getDOMNode().getModel().setText(text); + }; + + it('defaults to your project home path', function() { + const text = wrapper.find('atom-text-editor').getDOMNode().getModel().getText(); + assert.equal(text, path.join('home', 'me', 'codes')); + }); + + it('disables the initialize button with no project path', function() { + setTextIn('.github-ProjectPath atom-text-editor', ''); + + assert.isTrue(wrapper.find('button.icon-repo-create').prop('disabled')); + }); + + it('enables the initialize button when the project path is populated', function() { + setTextIn('.github-ProjectPath atom-text-editor', path.join('somewhere', 'else')); + + assert.isFalse(wrapper.find('button.icon-repo-create').prop('disabled')); + }); + + it('calls the acceptance callback', function() { + setTextIn('.github-ProjectPath atom-text-editor', '/somewhere/directory/'); + + wrapper.find('button.icon-repo-create').simulate('click'); + + assert.isTrue(didAccept.calledWith('/somewhere/directory/')); + }); + + it('calls the cancellation callback', function() { + wrapper.find('button.github-CancelButton').simulate('click'); + assert.isTrue(didCancel.called); + }); +}); From 2981190c80c38df413ec042c418c45ae2485cd79 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 15:15:35 -0400 Subject: [PATCH 138/161] Render the InitDialog when no working directory is known --- lib/controllers/root-controller.js | 52 +++++++++++++++- test/controllers/root-controller.test.js | 77 ++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index e93cb84b76..664023789c 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -16,6 +16,7 @@ import DockItem from '../views/dock-item'; import Resizer from '../views/resizer'; import Tabs from '../views/tabs'; import CloneDialog from '../views/clone-dialog'; +import InitDialog from '../views/init-dialog'; import CredentialDialog from '../views/credential-dialog'; import Commands, {Command} from '../views/commands'; import GithubTabController from './github-tab-controller'; @@ -82,6 +83,7 @@ export default class RootController extends React.Component { activeTab: props.savedState.activeTab || 0, cloneDialogActive: false, cloneDialogInProgress: false, + initDialogActive: false, credentialDialogQuery: null, }; @@ -131,6 +133,7 @@ export default class RootController extends React.Component { {this.renderStatusBarTile()} {this.renderPanels()} {(this.state.filePath && this.state.filePatch) ? this.renderFilePatchController() : null} + {this.renderInitDialog()} {this.renderCloneDialog()} {this.renderCredentialDialog()} {this.renderRepositoryConflictController()} @@ -280,6 +283,23 @@ export default class RootController extends React.Component { ); } + renderInitDialog() { + if (!this.state.initDialogActive) { + return null; + } + + return ( + + + + ); + } + renderCloneDialog() { if (!this.state.cloneDialogActive) { return null; @@ -338,8 +358,13 @@ export default class RootController extends React.Component { } @autobind - initializeRepo() { - this.props.createRepositoryForProjectPath(this.props.activeWorkingDirectory); + async initializeRepo() { + if (this.props.activeWorkingDirectory) { + await this.acceptInit(this.props.activeWorkingDirectory); + return; + } + + this.setState({initDialogActive: true}); } @autobind @@ -368,6 +393,29 @@ export default class RootController extends React.Component { this.setState({cloneDialogActive: false}); } + @autobind + async acceptInit(projectPath) { + try { + await this.props.createRepositoryForProjectPath(projectPath); + } catch (e) { + if (!(e instanceof GitError)) { + throw e; + } else { + this.props.notificationManager.addError( + `Unable to initialize git repository in ${projectPath}`, + {detail: e.stdErr, dismissable: true}, + ); + } + } finally { + this.setState({initDialogActive: false}); + } + } + + @autobind + cancelInit() { + this.setState({initDialogActive: false}); + } + @autobind async showFilePatchForPath(filePath, stagingStatus, {activate, amending} = {}) { if (!filePath) { return null; } diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index 7c1d87b8d5..bc9170c60a 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -360,6 +360,83 @@ describe('RootController', function() { }); }); + describe('initializeRepo', function() { + let createRepositoryForProjectPath, resolveInit, rejectInit; + + beforeEach(function() { + createRepositoryForProjectPath = sinon.stub().returns(new Promise((resolve, reject) => { + resolveInit = resolve; + rejectInit = reject; + })); + }); + + it('initializes the current working directory if there is one', function() { + app = React.cloneElement(app, { + createRepositoryForProjectPath, + activeWorkingDirectory: '/some/workdir', + }); + const wrapper = shallow(app); + + wrapper.instance().initializeRepo(); + resolveInit(); + + assert.isTrue(createRepositoryForProjectPath.calledWith('/some/workdir')); + }); + + it('renders the modal init panel', function() { + app = React.cloneElement(app, {createRepositoryForProjectPath}); + const wrapper = shallow(app); + + wrapper.instance().initializeRepo(); + + assert.lengthOf(wrapper.find('Panel').find({location: 'modal'}).find('InitDialog'), 1); + }); + + it('triggers the init callback on accept', function() { + app = React.cloneElement(app, {createRepositoryForProjectPath}); + const wrapper = shallow(app); + + wrapper.instance().initializeRepo(); + const dialog = wrapper.find('InitDialog'); + dialog.prop('didAccept')('/a/path'); + resolveInit(); + + assert.isTrue(createRepositoryForProjectPath.calledWith('/a/path')); + }); + + it('dismisses the init callback on cancel', function() { + app = React.cloneElement(app, {createRepositoryForProjectPath}); + const wrapper = shallow(app); + + wrapper.instance().initializeRepo(); + const dialog = wrapper.find('InitDialog'); + dialog.prop('didCancel')(); + + assert.isFalse(wrapper.find('InitDialog').exists()); + }); + + it('creates a notification if the init fails', async function() { + sinon.stub(notificationManager, 'addError'); + + app = React.cloneElement(app, {createRepositoryForProjectPath}); + const wrapper = shallow(app); + + wrapper.instance().initializeRepo(); + const dialog = wrapper.find('InitDialog'); + const acceptPromise = dialog.prop('didAccept')('/a/path'); + const err = new GitError('git init exited with status 1'); + err.stdErr = 'this is stderr'; + rejectInit(err); + await acceptPromise; + + assert.isFalse(wrapper.find('InitDialog').exists()); + assert.isTrue(notificationManager.addError.calledWith( + 'Unable to initialize git repository in /a/path', + sinon.match({detail: sinon.match(/this is stderr/)}), + )); + }); + }); + describe('github:clone', function() { let wrapper, cloneRepositoryForProjectPath, resolveClone, rejectClone; From 076f1d9a3b60298a21e8ea5d261eeec892bd92d4 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 15:20:28 -0400 Subject: [PATCH 139/161] createRepositoryForProjectPath() handles paths outside of known projects --- lib/github-package.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 57505ed260..90e52ad278 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -6,6 +6,7 @@ import React from 'react'; import ReactDom from 'react-dom'; import {autobind} from 'core-decorators'; +import {mkdirs} from './helpers'; import WorkdirCache from './models/workdir-cache'; import WorkdirContext, {absentWorkdirContext} from './models/workdir-context'; import WorkdirContextPool from './models/workdir-context-pool'; @@ -247,14 +248,16 @@ export default class GithubPackage { @autobind async createRepositoryForProjectPath(projectPath) { - if (!projectPath) { - throw new Error('Must specify a project path to create a repository for'); - } + await mkdirs(projectPath); const repository = this.contextPool.add(projectPath).getRepository(); await repository.init(); this.workdirCache.invalidate(projectPath); + if (!this.project.contains(projectPath)) { + this.project.addPath(projectPath); + } + await this.scheduleActiveContextUpdate(); } From 48911858ce5c6934ef875a25e433e83692b3ad07 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 15:38:59 -0400 Subject: [PATCH 140/161] Better errors when attempting to init an existing git repository --- lib/controllers/root-controller.js | 21 ++++++++------------- lib/models/repository-states/present.js | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index 664023789c..6a2c88c527 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -378,11 +378,10 @@ export default class RootController extends React.Component { try { await this.props.cloneRepositoryForProjectPath(remoteUrl, projectPath); } catch (e) { - if (!(e instanceof GitError)) { - throw e; - } else { - this.props.notificationManager.addError(`Unable to clone ${remoteUrl}`, {detail: e.stdErr, dismissable: true}); - } + this.props.notificationManager.addError( + `Unable to clone ${remoteUrl}`, + {detail: e.stdErr, dismissable: true}, + ); } finally { this.setState({cloneDialogInProgress: false, cloneDialogActive: false}); } @@ -398,14 +397,10 @@ export default class RootController extends React.Component { try { await this.props.createRepositoryForProjectPath(projectPath); } catch (e) { - if (!(e instanceof GitError)) { - throw e; - } else { - this.props.notificationManager.addError( - `Unable to initialize git repository in ${projectPath}`, - {detail: e.stdErr, dismissable: true}, - ); - } + this.props.notificationManager.addError( + `Unable to initialize git repository in ${projectPath}`, + {detail: e.stdErr, dismissable: true}, + ); } finally { this.setState({initDialogActive: false}); } diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index 455f522a64..c629f2e966 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -88,6 +88,20 @@ export default class Present extends State { this.didUpdate(); } + init() { + return super.init().catch(e => { + e.stdErr = 'This directory already contains a git repository'; + return Promise.reject(e); + }); + } + + clone() { + return super.clone().catch(e => { + e.stdErr = 'This directory already contains a git repository'; + return Promise.reject(e); + }); + } + // Git operations //////////////////////////////////////////////////////////////////////////////////////////////////// // Staging and unstaging From c66acdba632a649c5fc31e65d20e148febe62cd0 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Wed, 19 Apr 2017 15:53:00 -0400 Subject: [PATCH 141/161] Change the init message based on context --- lib/models/repository-states/absent.js | 4 ++++ lib/models/repository-states/state.js | 5 +++++ lib/models/repository-states/undetermined.js | 4 ++++ lib/models/repository.js | 1 + lib/views/git-tab-view.js | 5 ++++- 5 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/models/repository-states/absent.js b/lib/models/repository-states/absent.js index 3b5c3f8293..700dc27666 100644 --- a/lib/models/repository-states/absent.js +++ b/lib/models/repository-states/absent.js @@ -8,6 +8,10 @@ export default class Absent extends State { showGitTabInit() { return true; } + + hasDirectory() { + return false; + } } State.register(Absent); diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index d950125c1f..657cd04136 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -91,6 +91,11 @@ export default class State { return false; } + @shouldDelegate + hasDirectory() { + return true; + } + // Lifecycle actions ///////////////////////////////////////////////////////////////////////////////////////////////// // These generally default to rejecting a Promise with an error. diff --git a/lib/models/repository-states/undetermined.js b/lib/models/repository-states/undetermined.js index 25f1572ba8..d095b286f1 100644 --- a/lib/models/repository-states/undetermined.js +++ b/lib/models/repository-states/undetermined.js @@ -15,6 +15,10 @@ export default class Undetermined extends State { showGitTabLoading() { return true; } + + hasDirectory() { + return false; + } } State.register(Undetermined); diff --git a/lib/models/repository.js b/lib/models/repository.js index 3164a7a606..e5f0e55d2e 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -201,6 +201,7 @@ const delegates = [ 'showGitTabInitInProgress', 'showGitTabLoading', 'showStatusBarTiles', + 'hasDirectory', 'init', 'clone', diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js index 01ecd9034b..4e11e30d61 100644 --- a/lib/views/git-tab-view.js +++ b/lib/views/git-tab-view.js @@ -36,6 +36,9 @@ export default class GitTabView { render() { if (this.props.repository.showGitTabInit()) { const inProgress = this.props.repository.showGitTabInitInProgress(); + const message = this.props.repository.hasDirectory() ? + 'Initialize this project directory with a git repository' : + 'Initialize a new project directory with a git repository'; return (
    @@ -43,7 +46,7 @@ export default class GitTabView {
    -
    Initialize this project directory with a Git repository
    +
    {message}
    From 9b24ba4361295e33edae2499b5b5b1c072ca9c0b Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 11:35:11 -0400 Subject: [PATCH 142/161] Introduce behavioral variants of the Undetermined state --- lib/models/repository-states/undetermined.js | 15 +++++++++------ test/models/repository.test.js | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/lib/models/repository-states/undetermined.js b/lib/models/repository-states/undetermined.js index d095b286f1..25cd26bbaa 100644 --- a/lib/models/repository-states/undetermined.js +++ b/lib/models/repository-states/undetermined.js @@ -1,11 +1,10 @@ import State from './state'; -const UNDETERMINED_TIMEOUT = 2000; - export default class Undetermined extends State { - start() { - setTimeout(() => this.transitionTo('Absent'), UNDETERMINED_TIMEOUT); - return Promise.resolve(); + constructor(repository, loadingLike) { + super(repository); + + this.loadingLike = loadingLike; } isUndetermined() { @@ -13,7 +12,11 @@ export default class Undetermined extends State { } showGitTabLoading() { - return true; + return this.loadingLike; + } + + showGitTabInit() { + return !this.loadingLike; } hasDirectory() { diff --git a/test/models/repository.test.js b/test/models/repository.test.js index 2a0528fc33..464d634425 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -4,7 +4,7 @@ import dedent from 'dedent-js'; import temp from 'temp'; import util from 'util'; -import Repository from '../../lib/models/repository'; +import Repository, {ABSENTLIKE, LOADINGLIKE} from '../../lib/models/repository'; import {expectedDelegates} from '../../lib/models/repository-states'; import {cloneRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories, getHeadCommitOnRemote, assertEqualSortedArraysByKey} from '../helpers'; @@ -44,6 +44,20 @@ describe('Repository', function() { repository = Repository.absent(); assert.isTrue(repository.isInState('Absent')); }); + + it('begins in an absentlike Undetermined state with .undetermined(ABSENTLIKE)', function() { + repository = Repository.undetermined(ABSENTLIKE); + assert.isTrue(repository.isInState('Undetermined')); + assert.isFalse(repository.showGitTabLoading()); + assert.isTrue(repository.showGitTabInit()); + }); + + it('begins in a loadinglike Undetermined state with .undetermined(LOADINGLIKE)', function() { + repository = Repository.undetermined(LOADINGLIKE); + assert.isTrue(repository.isInState('Undetermined')); + assert.isTrue(repository.showGitTabLoading()); + assert.isFalse(repository.showGitTabInit()); + }); }); describe('init', function() { From e7807d34ffb79298d66b0e83fb0248f0c18cc6b1 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 11:35:40 -0400 Subject: [PATCH 143/161] Create undetermined WorkdirContexts with a behavioral variant --- lib/models/repository.js | 13 +++++++++---- lib/models/workdir-context.js | 11 +++++++---- test/models/workdir-context.test.js | 15 ++++++++++++--- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/lib/models/repository.js b/lib/models/repository.js index e5f0e55d2e..6bfef6103d 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -9,10 +9,15 @@ import {LoadingState, AbsentState, UndeterminedState} from './repository-states' const MERGE_MARKER_REGEX = /^(>|<){7} \S+$/m; +// Internal tokens used to designate the initial state of a Repository. const ABSENT = {}; const UNDETERMINED = {}; const LOADING = {}; +// Tokens used to control the behavior of the Undetermined state. +export const ABSENTLIKE = {}; +export const LOADINGLIKE = {}; + export class AbortMergeError extends Error { constructor(code, filePath) { super(); @@ -43,7 +48,7 @@ function getStateConstructor(token) { } export default class Repository { - constructor(workingDirectoryPath, gitStrategy = null, initialState = LOADING) { + constructor(workingDirectoryPath, gitStrategy = null, initialState = LOADING, ...stateArgs) { this.workingDirectoryPath = workingDirectoryPath; this.git = gitStrategy || CompositeGitStrategy.create(workingDirectoryPath); @@ -60,15 +65,15 @@ export default class Repository { }); }); - this.transitionTo(getStateConstructor(initialState)); + this.transitionTo(getStateConstructor(initialState), ...stateArgs); } static absent() { return new Repository(null, null, ABSENT); } - static undetermined() { - return new Repository(null, null, UNDETERMINED); + static undetermined(behavior) { + return new Repository(null, null, UNDETERMINED, behavior === LOADINGLIKE); } // State management ////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index c4d1c1a1c9..11c9ebbf05 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -1,12 +1,15 @@ import {Emitter, CompositeDisposable} from 'event-kit'; import {autobind} from 'core-decorators'; -import Repository from './repository'; +import Repository, {ABSENTLIKE, LOADINGLIKE} from './repository'; import ResolutionProgress from './conflicts/resolution-progress'; import FileSystemChangeObserver from './file-system-change-observer'; import WorkspaceChangeObserver from './workspace-change-observer'; +export {ABSENTLIKE, LOADINGLIKE}; + const initRepoState = Symbol('initRepoState'); +const initRepoArg = Symbol('initRepoArgs'); const ABSENT = {}; const UNDETERMINED = {}; @@ -32,7 +35,7 @@ export default class WorkdirContext { this.directory = directory; this.repository = (token => { if (token === ABSENT) { return Repository.absent(); } - if (token === UNDETERMINED) { return Repository.undetermined(); } + if (token === UNDETERMINED) { return Repository.undetermined(options[initRepoArg]); } return new Repository(directory); })(options[initRepoState]); @@ -61,8 +64,8 @@ export default class WorkdirContext { this.repositoryChangedState({from: null, to: this.repository.state}); } - static undetermined() { - return new WorkdirContext(null, {[initRepoState]: UNDETERMINED}); + static undetermined(behavior) { + return new WorkdirContext(null, {[initRepoState]: UNDETERMINED, [initRepoArg]: behavior}); } /** diff --git a/test/models/workdir-context.test.js b/test/models/workdir-context.test.js index 47edba2829..088719ce1f 100644 --- a/test/models/workdir-context.test.js +++ b/test/models/workdir-context.test.js @@ -2,7 +2,7 @@ import {CompositeDisposable, Disposable} from 'event-kit'; import {cloneRepository, sha} from '../helpers'; -import WorkdirContext, {absentWorkdirContext} from '../../lib/models/workdir-context'; +import WorkdirContext, {absentWorkdirContext, ABSENTLIKE, LOADINGLIKE} from '../../lib/models/workdir-context'; describe('WorkdirContext', function() { let context, workingDirectory, subs; @@ -127,8 +127,17 @@ describe('WorkdirContext', function() { assert.isTrue(absentWorkdirContext.getRepository().isAbsent()); }); - it('can be constructed containing a Repository in an undetermined state', function() { - const undetermined = WorkdirContext.undetermined(); + it('can be constructed containing an undetermined Repository that acts absent', function() { + const undetermined = WorkdirContext.undetermined(ABSENTLIKE); assert.isTrue(undetermined.getRepository().isUndetermined()); + assert.isFalse(undetermined.getRepository().showGitTabLoading()); + assert.isTrue(undetermined.getRepository().showGitTabInit()); + }); + + it("can be constructed containing an undetermined Repository that acts like it's loading", function() { + const undetermined = WorkdirContext.undetermined(LOADINGLIKE); + assert.isTrue(undetermined.getRepository().isUndetermined()); + assert.isTrue(undetermined.getRepository().showGitTabLoading()); + assert.isFalse(undetermined.getRepository().showGitTabInit()); }); }); From 29416d060318948c87de44aeb8d8543c6c594e82 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 11:36:59 -0400 Subject: [PATCH 144/161] Heuristically derive the initial context --- lib/github-package.js | 23 ++++++++-- test/github-package.test.js | 88 ++++++++++++++++++++++++++++++++++--- 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 90e52ad278..2349c70ec3 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -8,7 +8,7 @@ import {autobind} from 'core-decorators'; import {mkdirs} from './helpers'; import WorkdirCache from './models/workdir-cache'; -import WorkdirContext, {absentWorkdirContext} from './models/workdir-context'; +import WorkdirContext, {absentWorkdirContext, LOADINGLIKE, ABSENTLIKE} from './models/workdir-context'; import WorkdirContextPool from './models/workdir-context-pool'; import Repository from './models/repository'; import StyleCalculator from './models/style-calculator'; @@ -25,7 +25,8 @@ const defaultState = { }; export default class GithubPackage { - constructor(workspace, project, commandRegistry, notificationManager, tooltips, styles, config, confirm) { + constructor(workspace, project, commandRegistry, notificationManager, tooltips, styles, config, + confirm, getLoadSettings) { this.workspace = workspace; this.project = project; this.commandRegistry = commandRegistry; @@ -39,7 +40,7 @@ export default class GithubPackage { this.useLegacyPanels = false; this.activeContextQueue = new AsyncQueue(); - this.activeContext = WorkdirContext.undetermined(); + this.activeContext = this.getInitialContext(getLoadSettings()); this.workdirCache = new WorkdirCache(); this.contextPool = new WorkdirContextPool({ window, @@ -67,6 +68,22 @@ export default class GithubPackage { this.setupYardstick(); } + /** + * Synchronously determine the initial WorkdirContext of a newly constructed package instance based on: + * - the number of projects already loaded into the workspace + * - any initial paths specified in the load settings + */ + getInitialContext(loadSettings) { + const projectPathCount = this.project.getPaths().length; + const initLocationCount = (loadSettings.locationsToOpen || []).length; + + if (projectPathCount === 1 || (projectPathCount === 0 && initLocationCount === 1)) { + return WorkdirContext.undetermined(LOADINGLIKE); + } else { + return WorkdirContext.undetermined(ABSENTLIKE); + } + } + setupYardstick() { const stagingSeries = ['stageLine', 'stageHunk', 'unstageLine', 'unstageHunk']; diff --git a/test/github-package.test.js b/test/github-package.test.js index 8aac028f18..1a6791af6d 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -9,6 +9,7 @@ import GithubPackage from '../lib/github-package'; describe('GithubPackage', function() { let atomEnv, workspace, project, commandRegistry, notificationManager, config, confirm, tooltips, styles; + let getLoadSettings; let githubPackage, contextPool; beforeEach(function() { @@ -21,8 +22,10 @@ describe('GithubPackage', function() { config = atomEnv.config; confirm = atomEnv.confirm.bind(atomEnv); styles = atomEnv.styles; + getLoadSettings = atomEnv.getLoadSettings.bind(atomEnv); + githubPackage = new GithubPackage( - workspace, project, commandRegistry, notificationManager, tooltips, styles, config, confirm, + workspace, project, commandRegistry, notificationManager, tooltips, styles, config, confirm, getLoadSettings, ); contextPool = githubPackage.getContextPool(); @@ -39,6 +42,80 @@ describe('GithubPackage', function() { return updatePromise; } + describe('construction', function() { + let githubPackage1; + + afterEach(async function() { + if (githubPackage1) { + await githubPackage1.deactivate(); + } + }); + + async function constructWith(projectPaths, initialLocations) { + const realProjectPaths = await Promise.all( + projectPaths.map(projectPath => getTempDir(projectPath)), + ); + + project.setPaths(realProjectPaths); + const getLoadSettings1 = () => ({ + locationsToOpen: initialLocations.map(location => ({pathToOpen: location})), + }); + + githubPackage1 = new GithubPackage( + workspace, project, commandRegistry, notificationManager, tooltips, styles, config, confirm, getLoadSettings1, + ); + } + + function assertAbsentLike() { + const repository = githubPackage1.getActiveRepository(); + assert.isTrue(repository.isUndetermined()); + assert.isFalse(repository.showGitTabLoading()); + assert.isTrue(repository.showGitTabInit()); + } + + function assertLoadingLike() { + const repository = githubPackage1.getActiveRepository(); + assert.isTrue(repository.isUndetermined()); + assert.isTrue(repository.showGitTabLoading()); + assert.isFalse(repository.showGitTabInit()); + } + + it('with no projects or initial locations begins with an absent-like undetermined context', async function() { + await constructWith([], []); + assertAbsentLike(); + }); + + it('with one existing project begins with a loading-like undetermined context', async function() { + await constructWith(['one'], []); + assertLoadingLike(); + }); + + it('with several existing projects begins with an absent-like undetermined context', async function() { + await constructWith(['one', 'two'], []); + assertAbsentLike(); + }); + + it('with no projects but one initial location begins with a loading-like undetermined context', async function() { + await constructWith([], ['one']); + assertLoadingLike(); + }); + + it('with no projects and several initial locations begins with an absent-like undetermined context', async function() { + await constructWith([], ['one', 'two']); + assertAbsentLike(); + }); + + it('with one project and initial locations begins with a loading-like undetermined context', async function() { + await constructWith(['one'], ['two', 'three']); + assertLoadingLike(); + }); + + it('with several projects and an initial location begins with an absent-like undetermined context', async function() { + await constructWith(['one', 'two'], ['three']); + assertAbsentLike(); + }); + }); + describe('activate()', function() { it('begins with an undetermined repository context', async function() { await githubPackage.activate(); @@ -270,12 +347,9 @@ describe('GithubPackage', function() { await githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); const repository2 = contextPool.getContext(workdirPath2).getRepository(); + assert.isTrue(githubPackage.getActiveRepository().isUndetermined()); - assert.isTrue(githubPackage.getActiveRepository().isAbsent()); - - const updatePromise = githubPackage.getSwitchboard().getFinishActiveContextUpdatePromise(); - workspace.open(path.join(workdirPath2, 'b.txt')); - await updatePromise; + await contextUpdateAfter(() => workspace.open(path.join(workdirPath2, 'b.txt'))); assert.strictEqual(githubPackage.getActiveRepository(), repository2); }); @@ -560,7 +634,7 @@ describe('GithubPackage', function() { assert.isUndefined(payload.resolutionProgressByPath[workdirNoConflict]); const githubPackage1 = new GithubPackage( - workspace, project, commandRegistry, notificationManager, tooltips, styles, config, confirm, + workspace, project, commandRegistry, notificationManager, tooltips, styles, config, confirm, getLoadSettings, ); await githubPackage1.activate(payload); await githubPackage1.scheduleActiveContextUpdate(); From 0903db0a489e92b7b1de6d44c5509e908c001b7f Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 11:37:29 -0400 Subject: [PATCH 145/161] Pass getLoadSettings to the package --- lib/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/index.js b/lib/index.js index 4b0ed0033b..2387bc5b8b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -5,7 +5,7 @@ const entry = { initialize() { pack = new GithubPackage( atom.workspace, atom.project, atom.commands, atom.notifications, atom.tooltips, - atom.styles, atom.config, atom.confirm.bind(atom), + atom.styles, atom.config, atom.confirm.bind(atom), atom.getLoadSettings.bind(atom), ); }, }; From d07e864c12ef633c1d1059be169c63c9a159dee3 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 11:51:14 -0400 Subject: [PATCH 146/161] Use initialPaths instead of locationsToOpen --- lib/github-package.js | 4 ++-- test/github-package.test.js | 16 +++++++--------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 2349c70ec3..0ba801df7e 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -75,9 +75,9 @@ export default class GithubPackage { */ getInitialContext(loadSettings) { const projectPathCount = this.project.getPaths().length; - const initLocationCount = (loadSettings.locationsToOpen || []).length; + const initPathCount = (loadSettings.initialPaths || []).length; - if (projectPathCount === 1 || (projectPathCount === 0 && initLocationCount === 1)) { + if (projectPathCount === 1 || (projectPathCount === 0 && initPathCount === 1)) { return WorkdirContext.undetermined(LOADINGLIKE); } else { return WorkdirContext.undetermined(ABSENTLIKE); diff --git a/test/github-package.test.js b/test/github-package.test.js index 1a6791af6d..9adbf7f469 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -51,15 +51,13 @@ describe('GithubPackage', function() { } }); - async function constructWith(projectPaths, initialLocations) { + async function constructWith(projectPaths, initialPaths) { const realProjectPaths = await Promise.all( projectPaths.map(projectPath => getTempDir(projectPath)), ); project.setPaths(realProjectPaths); - const getLoadSettings1 = () => ({ - locationsToOpen: initialLocations.map(location => ({pathToOpen: location})), - }); + const getLoadSettings1 = () => ({initialPaths}); githubPackage1 = new GithubPackage( workspace, project, commandRegistry, notificationManager, tooltips, styles, config, confirm, getLoadSettings1, @@ -80,7 +78,7 @@ describe('GithubPackage', function() { assert.isFalse(repository.showGitTabInit()); } - it('with no projects or initial locations begins with an absent-like undetermined context', async function() { + it('with no projects or initial paths begins with an absent-like undetermined context', async function() { await constructWith([], []); assertAbsentLike(); }); @@ -95,22 +93,22 @@ describe('GithubPackage', function() { assertAbsentLike(); }); - it('with no projects but one initial location begins with a loading-like undetermined context', async function() { + it('with no projects but one initial path begins with a loading-like undetermined context', async function() { await constructWith([], ['one']); assertLoadingLike(); }); - it('with no projects and several initial locations begins with an absent-like undetermined context', async function() { + it('with no projects and several initial paths begins with an absent-like undetermined context', async function() { await constructWith([], ['one', 'two']); assertAbsentLike(); }); - it('with one project and initial locations begins with a loading-like undetermined context', async function() { + it('with one project and initial paths begins with a loading-like undetermined context', async function() { await constructWith(['one'], ['two', 'three']); assertLoadingLike(); }); - it('with several projects and an initial location begins with an absent-like undetermined context', async function() { + it('with several projects and an initial path begins with an absent-like undetermined context', async function() { await constructWith(['one', 'two'], ['three']); assertAbsentLike(); }); From bf2b039d6639cdc735107ae9cbfb9f5966f4c849 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 11:51:46 -0400 Subject: [PATCH 147/161] Account for renders that occur while fetching Promise data --- test/controllers/git-tab-controller.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/controllers/git-tab-controller.test.js b/test/controllers/git-tab-controller.test.js index 0a28c544e1..93b578dbb3 100644 --- a/test/controllers/git-tab-controller.test.js +++ b/test/controllers/git-tab-controller.test.js @@ -471,7 +471,7 @@ describe('GitTabController', function() { await assert.async.isDefined(controller.refs.gitTab.refs.stagingView); const stagingView = controller.refs.gitTab.refs.stagingView; - assert.equal(stagingView.props.mergeConflicts.length, 5); + await assert.async.equal(stagingView.props.mergeConflicts.length, 5); assert.equal(stagingView.props.stagedChanges.length, 0); const conflict1 = stagingView.props.mergeConflicts.filter(c => c.filePath === 'modified-on-both-ours.txt')[0]; @@ -522,7 +522,7 @@ describe('GitTabController', function() { await assert.async.isDefined(controller.refs.gitTab.refs.stagingView); const stagingView = controller.refs.gitTab.refs.stagingView; - assert.lengthOf(stagingView.props.unstagedChanges, 2); + await assert.async.lengthOf(stagingView.props.unstagedChanges, 2); // ensure staging the same file twice does not cause issues // second stage action is a no-op since the first staging operation is in flight From d63964809899b1758372777f3cc2735b43c89558 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 13:46:49 -0400 Subject: [PATCH 148/161] Split Undetermined into LoadingGuess and AbsentGuess --- lib/models/repository-states/absent-guess.js | 25 +++++++++++++++++ lib/models/repository-states/loading-guess.js | 25 +++++++++++++++++ lib/models/repository-states/state.js | 12 ++++++++- lib/models/repository-states/undetermined.js | 27 ------------------- lib/models/repository.js | 4 ++- 5 files changed, 64 insertions(+), 29 deletions(-) create mode 100644 lib/models/repository-states/absent-guess.js create mode 100644 lib/models/repository-states/loading-guess.js delete mode 100644 lib/models/repository-states/undetermined.js diff --git a/lib/models/repository-states/absent-guess.js b/lib/models/repository-states/absent-guess.js new file mode 100644 index 0000000000..c253ccc084 --- /dev/null +++ b/lib/models/repository-states/absent-guess.js @@ -0,0 +1,25 @@ +import State from './state'; + +export default class AbsentGuess extends State { + isAbsentGuess() { + return true; + } + + isUndetermined() { + return true; + } + + showGitTabLoading() { + return false; + } + + showGitTabInit() { + return true; + } + + hasDirectory() { + return false; + } +} + +State.register(AbsentGuess); diff --git a/lib/models/repository-states/loading-guess.js b/lib/models/repository-states/loading-guess.js new file mode 100644 index 0000000000..31cfdbc7a0 --- /dev/null +++ b/lib/models/repository-states/loading-guess.js @@ -0,0 +1,25 @@ +import State from './state'; + +export default class LoadingGuess extends State { + isLoadingGuess() { + return true; + } + + isUndetermined() { + return true; + } + + showGitTabLoading() { + return true; + } + + showGitTabInit() { + return false; + } + + hasDirectory() { + return false; + } +} + +State.register(LoadingGuess); diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index 657cd04136..125e4dbd3e 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -39,7 +39,12 @@ export default class State { // Allow external callers to identify which state a Repository is in if necessary. @shouldDelegate - isUndetermined() { + isLoadingGuess() { + return false; + } + + @shouldDelegate + isAbsentGuess() { return false; } @@ -71,6 +76,11 @@ export default class State { // Behavior probe predicates ///////////////////////////////////////////////////////////////////////////////////////// // Determine specific rendering behavior based on the current state. + @shouldDelegate + isUndetermined() { + return false; + } + @shouldDelegate showGitTabInit() { return false; diff --git a/lib/models/repository-states/undetermined.js b/lib/models/repository-states/undetermined.js deleted file mode 100644 index 25cd26bbaa..0000000000 --- a/lib/models/repository-states/undetermined.js +++ /dev/null @@ -1,27 +0,0 @@ -import State from './state'; - -export default class Undetermined extends State { - constructor(repository, loadingLike) { - super(repository); - - this.loadingLike = loadingLike; - } - - isUndetermined() { - return true; - } - - showGitTabLoading() { - return this.loadingLike; - } - - showGitTabInit() { - return !this.loadingLike; - } - - hasDirectory() { - return false; - } -} - -State.register(Undetermined); diff --git a/lib/models/repository.js b/lib/models/repository.js index 6bfef6103d..e316d9a1b3 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -195,13 +195,15 @@ export default class Repository { // This list should match the methods decorated with @shouldDelegate in `lib/models/repository-states/state.js`. A test // case in `test/models/repository.test.js` ensures that these sets match. const delegates = [ - 'isUndetermined', + 'isLoadingGuess', + 'isAbsentGuess', 'isAbsent', 'isLoading', 'isEmpty', 'isPresent', 'isDestroyed', + 'isUndetermined', 'showGitTabInit', 'showGitTabInitInProgress', 'showGitTabLoading', From 46b2aa31d23c0456cabeed0b2d19e660c9d1c6af Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 13:47:28 -0400 Subject: [PATCH 149/161] Export state constructors as-is --- lib/models/repository-states/index.js | 8 +++--- lib/models/repository.js | 36 +++++++++------------------ 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/lib/models/repository-states/index.js b/lib/models/repository-states/index.js index e4ed4ded2c..7637057b16 100644 --- a/lib/models/repository-states/index.js +++ b/lib/models/repository-states/index.js @@ -1,8 +1,10 @@ export {expectedDelegates} from './state'; -export {default as LoadingState} from './loading'; -export {default as UndeterminedState} from './undetermined'; -export {default as AbsentState} from './absent'; +// Load and export possible initial states +export {default as Loading} from './loading'; +export {default as LoadingGuess} from './loading-guess'; +export {default as Absent} from './absent'; +export {default as AbsentGuess} from './absent-guess'; // Load and register remaining states import './empty'; diff --git a/lib/models/repository.js b/lib/models/repository.js index e316d9a1b3..1c19776993 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -5,18 +5,12 @@ import {Emitter} from 'event-kit'; import CompositeGitStrategy from '../composite-git-strategy'; import {readFile} from '../helpers'; import Remote, {nullRemote} from './remote'; -import {LoadingState, AbsentState, UndeterminedState} from './repository-states'; +import {Loading, Absent, LoadingGuess, AbsentGuess} from './repository-states'; const MERGE_MARKER_REGEX = /^(>|<){7} \S+$/m; -// Internal tokens used to designate the initial state of a Repository. -const ABSENT = {}; -const UNDETERMINED = {}; -const LOADING = {}; - -// Tokens used to control the behavior of the Undetermined state. -export const ABSENTLIKE = {}; -export const LOADINGLIKE = {}; +// Internal option keys used to designate the desired initial state of a Repository. +const initialStateSym = Symbol('initialState'); export class AbortMergeError extends Error { constructor(code, filePath) { @@ -37,18 +31,8 @@ export class CommitError extends Error { } } -function getStateConstructor(token) { - if (token === ABSENT) { - return AbsentState; - } else if (token === UNDETERMINED) { - return UndeterminedState; - } else { - return LoadingState; - } -} - export default class Repository { - constructor(workingDirectoryPath, gitStrategy = null, initialState = LOADING, ...stateArgs) { + constructor(workingDirectoryPath, gitStrategy = null, options = {}) { this.workingDirectoryPath = workingDirectoryPath; this.git = gitStrategy || CompositeGitStrategy.create(workingDirectoryPath); @@ -65,15 +49,19 @@ export default class Repository { }); }); - this.transitionTo(getStateConstructor(initialState), ...stateArgs); + this.transitionTo(options[initialStateSym] || Loading); } static absent() { - return new Repository(null, null, ABSENT); + return new Repository(null, null, {[initialStateSym]: Absent}); + } + + static loadingGuess() { + return new Repository(null, null, {[initialStateSym]: LoadingGuess}); } - static undetermined(behavior) { - return new Repository(null, null, UNDETERMINED, behavior === LOADINGLIKE); + static absentGuess() { + return new Repository(null, null, {[initialStateSym]: AbsentGuess}); } // State management ////////////////////////////////////////////////////////////////////////////////////////////////// From dbe22c4f951231109cde131c2114fc9210b40180 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 13:47:45 -0400 Subject: [PATCH 150/161] Move initial context guess into WorkdirContext --- lib/github-package.js | 25 +++++++------------------ lib/models/workdir-context.js | 29 +++++++++++++---------------- 2 files changed, 20 insertions(+), 34 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 0ba801df7e..c14115f095 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -8,7 +8,7 @@ import {autobind} from 'core-decorators'; import {mkdirs} from './helpers'; import WorkdirCache from './models/workdir-cache'; -import WorkdirContext, {absentWorkdirContext, LOADINGLIKE, ABSENTLIKE} from './models/workdir-context'; +import WorkdirContext, {absentWorkdirContext} from './models/workdir-context'; import WorkdirContextPool from './models/workdir-context-pool'; import Repository from './models/repository'; import StyleCalculator from './models/style-calculator'; @@ -39,8 +39,13 @@ export default class GithubPackage { this.confirm = confirm; this.useLegacyPanels = false; + const criteria = { + projectPathCount: this.project.getPaths().length, + initPathCount: (getLoadSettings().initialPaths || []) || 0, + }; + this.activeContextQueue = new AsyncQueue(); - this.activeContext = this.getInitialContext(getLoadSettings()); + this.activeContext = WorkdirContext.guess(criteria); this.workdirCache = new WorkdirCache(); this.contextPool = new WorkdirContextPool({ window, @@ -68,22 +73,6 @@ export default class GithubPackage { this.setupYardstick(); } - /** - * Synchronously determine the initial WorkdirContext of a newly constructed package instance based on: - * - the number of projects already loaded into the workspace - * - any initial paths specified in the load settings - */ - getInitialContext(loadSettings) { - const projectPathCount = this.project.getPaths().length; - const initPathCount = (loadSettings.initialPaths || []).length; - - if (projectPathCount === 1 || (projectPathCount === 0 && initPathCount === 1)) { - return WorkdirContext.undetermined(LOADINGLIKE); - } else { - return WorkdirContext.undetermined(ABSENTLIKE); - } - } - setupYardstick() { const stagingSeries = ['stageLine', 'stageHunk', 'unstageLine', 'unstageHunk']; diff --git a/lib/models/workdir-context.js b/lib/models/workdir-context.js index 11c9ebbf05..3d796a7829 100644 --- a/lib/models/workdir-context.js +++ b/lib/models/workdir-context.js @@ -1,18 +1,12 @@ import {Emitter, CompositeDisposable} from 'event-kit'; import {autobind} from 'core-decorators'; -import Repository, {ABSENTLIKE, LOADINGLIKE} from './repository'; +import Repository from './repository'; import ResolutionProgress from './conflicts/resolution-progress'; import FileSystemChangeObserver from './file-system-change-observer'; import WorkspaceChangeObserver from './workspace-change-observer'; -export {ABSENTLIKE, LOADINGLIKE}; - -const initRepoState = Symbol('initRepoState'); -const initRepoArg = Symbol('initRepoArgs'); - -const ABSENT = {}; -const UNDETERMINED = {}; +const createRepoSym = Symbol('createRepo'); /* * Bundle of model objects associated with a git working directory. @@ -33,11 +27,7 @@ export default class WorkdirContext { */ constructor(directory, options = {}) { this.directory = directory; - this.repository = (token => { - if (token === ABSENT) { return Repository.absent(); } - if (token === UNDETERMINED) { return Repository.undetermined(options[initRepoArg]); } - return new Repository(directory); - })(options[initRepoState]); + this.repository = (options[createRepoSym] || (() => new Repository(directory)))(); const {window: theWindow, workspace, promptCallback} = options; this.savedResolutionProgress = options.savedResolutionProgress || {}; @@ -64,8 +54,15 @@ export default class WorkdirContext { this.repositoryChangedState({from: null, to: this.repository.state}); } - static undetermined(behavior) { - return new WorkdirContext(null, {[initRepoState]: UNDETERMINED, [initRepoArg]: behavior}); + static guess(options) { + const projectPathCount = options.projectPathCount || 0; + const initPathCount = options.initPathCount || 0; + + const createRepo = (projectPathCount === 1 || (projectPathCount === 0 && initPathCount === 1)) ? + () => Repository.loadingGuess() : + () => Repository.absentGuess(); + + return new WorkdirContext(null, {[createRepoSym]: createRepo}); } /** @@ -189,7 +186,7 @@ export default class WorkdirContext { class AbsentWorkdirContext extends WorkdirContext { constructor() { - super(null, {[initRepoState]: ABSENT}); + super(null, {[createRepoSym]: () => Repository.absent()}); } isPresent() { From 0e5763320c6b07a0e23d487d88451233d0bd7fb0 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 13:53:54 -0400 Subject: [PATCH 151/161] Update tests to use static constructor methods --- lib/github-package.js | 2 +- test/models/repository.test.js | 14 +++++++------- test/models/workdir-context.test.js | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index c14115f095..772f084253 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -41,7 +41,7 @@ export default class GithubPackage { const criteria = { projectPathCount: this.project.getPaths().length, - initPathCount: (getLoadSettings().initialPaths || []) || 0, + initPathCount: (getLoadSettings().initialPaths || []).length, }; this.activeContextQueue = new AsyncQueue(); diff --git a/test/models/repository.test.js b/test/models/repository.test.js index 464d634425..075d80c070 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -4,7 +4,7 @@ import dedent from 'dedent-js'; import temp from 'temp'; import util from 'util'; -import Repository, {ABSENTLIKE, LOADINGLIKE} from '../../lib/models/repository'; +import Repository from '../../lib/models/repository'; import {expectedDelegates} from '../../lib/models/repository-states'; import {cloneRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories, getHeadCommitOnRemote, assertEqualSortedArraysByKey} from '../helpers'; @@ -45,16 +45,16 @@ describe('Repository', function() { assert.isTrue(repository.isInState('Absent')); }); - it('begins in an absentlike Undetermined state with .undetermined(ABSENTLIKE)', function() { - repository = Repository.undetermined(ABSENTLIKE); - assert.isTrue(repository.isInState('Undetermined')); + it('begins in an AbsentGuess state with .absentGuess()', function() { + repository = Repository.absentGuess(); + assert.isTrue(repository.isInState('AbsentGuess')); assert.isFalse(repository.showGitTabLoading()); assert.isTrue(repository.showGitTabInit()); }); - it('begins in a loadinglike Undetermined state with .undetermined(LOADINGLIKE)', function() { - repository = Repository.undetermined(LOADINGLIKE); - assert.isTrue(repository.isInState('Undetermined')); + it('begins in a LoadingGuess state with .guess()', function() { + repository = Repository.loadingGuess(); + assert.isTrue(repository.isInState('LoadingGuess')); assert.isTrue(repository.showGitTabLoading()); assert.isFalse(repository.showGitTabInit()); }); diff --git a/test/models/workdir-context.test.js b/test/models/workdir-context.test.js index 088719ce1f..e92c718e23 100644 --- a/test/models/workdir-context.test.js +++ b/test/models/workdir-context.test.js @@ -2,7 +2,7 @@ import {CompositeDisposable, Disposable} from 'event-kit'; import {cloneRepository, sha} from '../helpers'; -import WorkdirContext, {absentWorkdirContext, ABSENTLIKE, LOADINGLIKE} from '../../lib/models/workdir-context'; +import WorkdirContext, {absentWorkdirContext} from '../../lib/models/workdir-context'; describe('WorkdirContext', function() { let context, workingDirectory, subs; @@ -128,14 +128,14 @@ describe('WorkdirContext', function() { }); it('can be constructed containing an undetermined Repository that acts absent', function() { - const undetermined = WorkdirContext.undetermined(ABSENTLIKE); + const undetermined = WorkdirContext.guess({}); assert.isTrue(undetermined.getRepository().isUndetermined()); assert.isFalse(undetermined.getRepository().showGitTabLoading()); assert.isTrue(undetermined.getRepository().showGitTabInit()); }); it("can be constructed containing an undetermined Repository that acts like it's loading", function() { - const undetermined = WorkdirContext.undetermined(LOADINGLIKE); + const undetermined = WorkdirContext.guess({projectPathCount: 1}); assert.isTrue(undetermined.getRepository().isUndetermined()); assert.isTrue(undetermined.getRepository().showGitTabLoading()); assert.isFalse(undetermined.getRepository().showGitTabInit()); From ec61886c4fbac1b428880bdf4beedc44037103be Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 14:12:56 -0400 Subject: [PATCH 152/161] Document Repository states --- lib/models/repository-states/absent-guess.js | 4 ++++ lib/models/repository-states/absent.js | 3 +++ lib/models/repository-states/cloning.js | 3 +++ lib/models/repository-states/destroyed.js | 3 +++ lib/models/repository-states/empty.js | 3 +++ lib/models/repository-states/initializing.js | 3 +++ lib/models/repository-states/loading-guess.js | 4 ++++ lib/models/repository-states/loading.js | 4 ++++ lib/models/repository-states/present.js | 5 +++++ 9 files changed, 32 insertions(+) diff --git a/lib/models/repository-states/absent-guess.js b/lib/models/repository-states/absent-guess.js index c253ccc084..0f50b88082 100644 --- a/lib/models/repository-states/absent-guess.js +++ b/lib/models/repository-states/absent-guess.js @@ -1,5 +1,9 @@ import State from './state'; +/** + * Initial state to be used when we anticipate that the workspace will contain zero or many projects once bootstrapping + * has completed. Presents in the UI like the Absent state, but is "sticky" during the initial package activation. + */ export default class AbsentGuess extends State { isAbsentGuess() { return true; diff --git a/lib/models/repository-states/absent.js b/lib/models/repository-states/absent.js index 700dc27666..c282392e7a 100644 --- a/lib/models/repository-states/absent.js +++ b/lib/models/repository-states/absent.js @@ -1,5 +1,8 @@ import State from './state'; +/** + * No working directory is available in the workspace. + */ export default class Absent extends State { isAbsent() { return true; diff --git a/lib/models/repository-states/cloning.js b/lib/models/repository-states/cloning.js index a1eb34223b..8afe1c081a 100644 --- a/lib/models/repository-states/cloning.js +++ b/lib/models/repository-states/cloning.js @@ -2,6 +2,9 @@ import {mkdirs} from '../../helpers'; import State from './state'; +/** + * Git is asynchronously cloning a repository into this working directory. + */ export default class Cloning extends State { constructor(repository, remoteUrl) { super(repository); diff --git a/lib/models/repository-states/destroyed.js b/lib/models/repository-states/destroyed.js index 5144f35bba..0273989a9d 100644 --- a/lib/models/repository-states/destroyed.js +++ b/lib/models/repository-states/destroyed.js @@ -1,5 +1,8 @@ import State from './state'; +/** + * The package is being cleaned up or the context is being disposed some other way. + */ export default class Destroyed extends State { start() { this.didDestroy(); diff --git a/lib/models/repository-states/empty.js b/lib/models/repository-states/empty.js index 720973a758..116ceae31c 100644 --- a/lib/models/repository-states/empty.js +++ b/lib/models/repository-states/empty.js @@ -1,5 +1,8 @@ import State from './state'; +/** + * The working directory exists, but contains no git repository yet. + */ export default class Empty extends State { isEmpty() { return true; diff --git a/lib/models/repository-states/initializing.js b/lib/models/repository-states/initializing.js index 5d3fa60621..385203d6ca 100644 --- a/lib/models/repository-states/initializing.js +++ b/lib/models/repository-states/initializing.js @@ -1,5 +1,8 @@ import State from './state'; +/** + * Git is asynchronously initializing a new repository in this working directory. + */ export default class Initializing extends State { async start() { await this.git().init(this.workdir()); diff --git a/lib/models/repository-states/loading-guess.js b/lib/models/repository-states/loading-guess.js index 31cfdbc7a0..a323ff7c07 100644 --- a/lib/models/repository-states/loading-guess.js +++ b/lib/models/repository-states/loading-guess.js @@ -1,5 +1,9 @@ import State from './state'; +/** + * Initial state to be used when we anticipate that the workspace will contain a single project once bootstrapping + * has completed. Presents in the UI like the Loading state. + */ export default class LoadingGuess extends State { isLoadingGuess() { return true; diff --git a/lib/models/repository-states/loading.js b/lib/models/repository-states/loading.js index dd9503faa5..a70420f557 100644 --- a/lib/models/repository-states/loading.js +++ b/lib/models/repository-states/loading.js @@ -1,5 +1,9 @@ import State from './state'; +/** + * Initial state to be used when it's uncertain whether or not a git repository is present in a working directory. If + * it is a git repository, transition to Present, otherwise transition to Empty. + */ export default class Loading extends State { async start() { if (await this.git().isGitRepository()) { diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index c629f2e966..603a902af5 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -60,6 +60,11 @@ function invalidate() { }; } +/** + * State used when the working directory contains a valid git repository and can be interacted with. Performs + * actual git operations, caching the results, and broadcasts `onDidUpdate` events when write actions are + * performed. + */ export default class Present extends State { constructor(repository, history) { super(repository); From 994804d25c6080945301dec048df43abfd5ca082 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 14:14:04 -0400 Subject: [PATCH 153/161] :fire: unused function --- lib/models/repository-states/state.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index 125e4dbd3e..95a8dd743c 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -471,10 +471,6 @@ export default class State { } } -function unsupportedOperation(self, opName) { - throw new Error(`${opName} is not available in ${self} state`); -} - function unsupportedOperationPromise(self, opName) { return Promise.reject(new Error(`${opName} is not available in ${self} state`)); } From 81d8e405c727a2839c3c93606b0044a3e61d253c Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 14:14:15 -0400 Subject: [PATCH 154/161] Docs docs docs (without a k) --- lib/models/repository.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/models/repository.js b/lib/models/repository.js index 1c19776993..e3f8d340a7 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -180,8 +180,12 @@ export default class Repository { } // The methods named here will be delegated to the current State. +// // This list should match the methods decorated with @shouldDelegate in `lib/models/repository-states/state.js`. A test // case in `test/models/repository.test.js` ensures that these sets match. +// +// Duplicated here rather than just using `expectedDelegates` directly so that this file is grep-friendly for answering +// the question of "what all can a Repository do exactly". const delegates = [ 'isLoadingGuess', 'isAbsentGuess', From f2e771f0063ca28e3dcb16f0c269cf828449d151 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 14:36:17 -0400 Subject: [PATCH 155/161] State transition diagram --- lib/models/repository-states/README.md | 11 +++++++++++ lib/models/repository-states/repo-states.svg | 2 ++ 2 files changed, 13 insertions(+) create mode 100644 lib/models/repository-states/README.md create mode 100644 lib/models/repository-states/repo-states.svg diff --git a/lib/models/repository-states/README.md b/lib/models/repository-states/README.md new file mode 100644 index 0000000000..21629d9c74 --- /dev/null +++ b/lib/models/repository-states/README.md @@ -0,0 +1,11 @@ +This directory contains the various states that a `Repository` model can assume. Each state subclasses [`State`](./state), which provides each state with: + +* Trivial implementations of the state-dependent Repository functionality that return "empty" results where possible; Promises that resolve to empty arrays, null-object `Remote` or `Branch` implementations, `false`. This allows the UI components to freely query the current Repository for data regardless of whether an actual git repository exists or not. +* Action and transition methods that throw exceptions or reject Promises. +* UI state probe methods to allow individual states to customize the way that certain UI components respond while each state is active. For example, if `showGitTabLoading` is overridden to return `false`, the `.is-loading` class is added to the Git tab DOM node to allow it to be styled differently. + +Here's the current state transition diagram: + +![state transition diagram](./repo-states.svg) + +Only the initial states (`Absent`, `AbsentGuess`, `Loading`, and `LoadingGuess`) are exported from the directory module. diff --git a/lib/models/repository-states/repo-states.svg b/lib/models/repository-states/repo-states.svg new file mode 100644 index 0000000000..5bbe1a4b82 --- /dev/null +++ b/lib/models/repository-states/repo-states.svg @@ -0,0 +1,2 @@ + +
    no projects
    no projects
    Absent
    Absent
    LoadingGuess
    LoadingGuess
    AbsentGuess
    AbsentGuess
    no .git directory
    no .git directory
    valid .git directory
    valid .git directory
    Loading
    Loading
    Present
    Present
    init
    init
    clone
    clone
    Empty
    Empty
    init complete
    init complete
    Initializing
    Initializing
    clone complete
    clone complete
    Cloning
    Cloning
    initial load
    1 project / 1 init path
    initial load<div>1 project / 1 init path</div>
    initial load
    0/>1 projects / init paths
    initial load<div>0/&gt;1 projects / init paths</div>
    \ No newline at end of file From 311a7c8537e6caa255d6c4868cd11fbba4d6388b Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 14:43:18 -0400 Subject: [PATCH 156/161] Right, no SVGs in markdown renders --- lib/models/repository-states/repo-states.png | Bin 0 -> 61057 bytes lib/models/repository-states/repo-states.svg | 2 -- 2 files changed, 2 deletions(-) create mode 100644 lib/models/repository-states/repo-states.png delete mode 100644 lib/models/repository-states/repo-states.svg diff --git a/lib/models/repository-states/repo-states.png b/lib/models/repository-states/repo-states.png new file mode 100644 index 0000000000000000000000000000000000000000..d31b310514cd4e8cf488d317dafabeaaa3dc16e0 GIT binary patch literal 61057 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 -
    no projects
    no projects
    Absent
    Absent
    LoadingGuess
    LoadingGuess
    AbsentGuess
    AbsentGuess
    no .git directory
    no .git directory
    valid .git directory
    valid .git directory
    Loading
    Loading
    Present
    Present
    init
    init
    clone
    clone
    Empty
    Empty
    init complete
    init complete
    Initializing
    Initializing
    clone complete
    clone complete
    Cloning
    Cloning
    initial load
    1 project / 1 init path
    initial load<div>1 project / 1 init path</div>
    initial load
    0/>1 projects / init paths
    initial load<div>0/&gt;1 projects / init paths</div>
    \ No newline at end of file From 170d8cc26a9704600189f9dfa662e1ac127247d0 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 14:44:36 -0400 Subject: [PATCH 157/161] It helps if the link is right --- lib/models/repository-states/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/repository-states/README.md b/lib/models/repository-states/README.md index 21629d9c74..28d6f526c7 100644 --- a/lib/models/repository-states/README.md +++ b/lib/models/repository-states/README.md @@ -6,6 +6,6 @@ This directory contains the various states that a `Repository` model can assume. Here's the current state transition diagram: -![state transition diagram](./repo-states.svg) +![state transition diagram](./repo-states.png) Only the initial states (`Absent`, `AbsentGuess`, `Loading`, and `LoadingGuess`) are exported from the directory module. From 5f80ec2a63324a8724dd68330d76a5f87fbeccaa Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 15:07:36 -0400 Subject: [PATCH 158/161] Handle the removal of the last project folder --- lib/github-package.js | 5 +++++ test/github-package.test.js | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/github-package.js b/lib/github-package.js index 772f084253..4390b4ff48 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -352,6 +352,11 @@ export default class GithubPackage { return this.contextPool.getContext(activeWorkingDir || projectPath); } + if (this.project.getPaths().length === 0 && !this.activeContext.getRepository().isUndetermined()) { + // No projects. Revert to the absent context unless we've guessed that more projects are on the way. + return absentWorkdirContext; + } + // Restore models from saved state. Will return a NullWorkdirContext if this path is not presently // resident in the pool. const savedWorkingDir = savedState.activeRepositoryPath; diff --git a/test/github-package.test.js b/test/github-package.test.js index 9adbf7f469..767a85795f 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -331,6 +331,24 @@ describe('GithubPackage', function() { assert.isFalse(contextPool.getContext(workdirPath3).isPresent()); assert.isTrue(contextPool.getContext(workdirPath2).isPresent()); }); + + it('returns to an absent context when the last project folder is removed', async function() { + const workdirPath = await cloneRepository('three-files'); + project.setPaths([workdirPath]); + await contextUpdateAfter(() => githubPackage.activate()); + + assert.isTrue(githubPackage.getActiveRepository().isLoading()); + + await contextUpdateAfter(() => project.setPaths([])); + + assert.isTrue(githubPackage.getActiveRepository().isAbsent()); + }); + + it('does not transition away from an absent guess when no project folders are present', async function() { + await contextUpdateAfter(() => githubPackage.activate()); + + assert.isTrue(githubPackage.getActiveRepository().isAbsentGuess()); + }); }); describe('when the active pane item changes', function() { From 288c954cc95f559c479a6a336612d8208bad1da3 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 15:14:40 -0400 Subject: [PATCH 159/161] File extensions are important in links --- lib/models/repository-states/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/repository-states/README.md b/lib/models/repository-states/README.md index 28d6f526c7..24ceb38527 100644 --- a/lib/models/repository-states/README.md +++ b/lib/models/repository-states/README.md @@ -1,4 +1,4 @@ -This directory contains the various states that a `Repository` model can assume. Each state subclasses [`State`](./state), which provides each state with: +This directory contains the various states that a `Repository` model can assume. Each state subclasses [`State`](./state.js), which provides each state with: * Trivial implementations of the state-dependent Repository functionality that return "empty" results where possible; Promises that resolve to empty arrays, null-object `Remote` or `Branch` implementations, `false`. This allows the UI components to freely query the current Repository for data regardless of whether an actual git repository exists or not. * Action and transition methods that throw exceptions or reject Promises. From 8a8f4bde477ae6b66758a387b2e8c766b4bf499d Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 15:43:14 -0400 Subject: [PATCH 160/161] Avoid recomputing the active item workdir --- lib/github-package.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/github-package.js b/lib/github-package.js index 4390b4ff48..c8acb8680c 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -330,19 +330,20 @@ export default class GithubPackage { ); const activeItemPath = pathForPaneItem(this.workspace.getActivePaneItem()); - if (activeItemPath && !this.project.contains(activeItemPath)) { - const activeItemWorkdir = await this.workdirCache.find(activeItemPath); - if (activeItemWorkdir) { - workdirs.add(activeItemWorkdir); - } + let activeItemWorkdir = null; + if (activeItemPath) { + activeItemWorkdir = await this.workdirCache.find(activeItemPath); + } + + if (activeItemWorkdir && !this.project.contains(activeItemPath)) { + workdirs.add(activeItemWorkdir); } this.contextPool.set(workdirs, savedState); if (activeItemPath) { // Prefer an active item - const activeWorkingDir = await this.workdirCache.find(activeItemPath); - return this.contextPool.getContext(activeWorkingDir || activeItemPath); + return this.contextPool.getContext(activeItemWorkdir || activeItemPath); } if (this.project.getPaths().length === 1) { From 705ed71975080e58eabdf9283cee8ddfeb6f183c Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 20 Apr 2017 15:43:40 -0400 Subject: [PATCH 161/161] Pass even if the Repository already loaded --- test/github-package.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/github-package.test.js b/test/github-package.test.js index 767a85795f..bd42fbbfd9 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -337,7 +337,7 @@ describe('GithubPackage', function() { project.setPaths([workdirPath]); await contextUpdateAfter(() => githubPackage.activate()); - assert.isTrue(githubPackage.getActiveRepository().isLoading()); + assert.isTrue(githubPackage.getActiveRepository().isLoading() || githubPackage.getActiveRepository().isPresent()); await contextUpdateAfter(() => project.setPaths([]));