diff --git a/lib/git-shell-out-strategy.js b/lib/git-shell-out-strategy.js index 6ea5489818..9e4b342d89 100644 --- a/lib/git-shell-out-strategy.js +++ b/lib/git-shell-out-strategy.js @@ -510,14 +510,17 @@ export default class GitShellOutStrategy { * Branches */ async getCurrentBranch() { - const content = await readFile(path.join(this.workingDir, '.git', 'HEAD')); - if (content.startsWith('ref: ')) { + try { // The common case: you're on a branch return { name: (await this.exec(['symbolic-ref', '--short', 'HEAD'])).trim(), isDetached: false, }; - } else { + } catch (e) { + if (!/not a symbolic ref/.test(e.stdErr)) { + throw e; + } + // Detached HEAD return { name: (await this.exec(['describe', '--contains', '--all', 'HEAD'])).trim(), diff --git a/lib/models/file-system-change-observer.js b/lib/models/file-system-change-observer.js index 931c4b667a..db911bab99 100644 --- a/lib/models/file-system-change-observer.js +++ b/lib/models/file-system-change-observer.js @@ -57,16 +57,16 @@ export default class FileSystemChangeObserver { const isNonGitFile = event => !event.directory.split(path.sep).includes('.git') && event.file !== '.git'; const isWatchedGitFile = event => { return ['config', 'index', 'HEAD', 'MERGE_HEAD'].includes(event.file || event.newFile) || - event.directory.includes(path.join('.git', 'refs', 'remotes')); + event.directory.includes(path.join('.git', 'refs')); }; const filteredEvents = events.filter(e => isNonGitFile(e) || isWatchedGitFile(e)); if (filteredEvents.length) { this.logger.showEvents(filteredEvents); - this.emitter.emit('did-change'); + this.didChange(filteredEvents); const workdirOrHeadEvent = filteredEvents.find(e => !['config', 'index'].includes(e.file || e.newFile)); if (workdirOrHeadEvent) { this.logger.showWorkdirOrHeadEvents(); - this.emitter.emit('did-change-workdir-or-head'); + this.didChangeWorkdirOrHead(); } } }, diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index 738c5f3c1c..b6fbd3aebb 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -11,47 +11,22 @@ import Branch from '../branch'; import Remote from '../remote'; import Commit from '../commit'; -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; - } - - 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.) + * Decorator for an async method that invalidates the cache after execution (regardless of success or failure). + * Optionally parameterized by a function that accepts the same arguments as the function that returns the list of cache + * keys to invalidate. */ -function invalidate() { +function invalidate(spec) { return function(target, name, descriptor) { const original = descriptor.value; descriptor.value = function(...args) { return original.apply(this, args).then( result => { - this.refresh(); + this.acceptInvalidation(spec, args); return result; }, err => { - this.refresh(); + this.acceptInvalidation(spec, args); return Promise.reject(err); }, ); @@ -88,6 +63,68 @@ export default class Present extends State { return true; } + acceptInvalidation(spec, args) { + const keys = spec(...args); + this.cache.invalidate(keys); + this.didUpdate(); + } + + observeFilesystemChange(events) { + const keys = new Set(); + for (let i = 0; i < events.length; i++) { + const event = events[i]; + + const fullPath = path.join(event.directory, event.file || event.newFile); + + const endsWith = (...segments) => fullPath.endsWith(path.join(...segments)); + const includes = (...segments) => fullPath.includes(path.join(...segments)); + + if (endsWith('.git', 'index')) { + keys.add(Keys.changedFiles); + keys.add(Keys.stagedChangesSinceParentCommit); + keys.add(Keys.filePatch.all); + keys.add(Keys.index.all); + continue; + } + + if (endsWith('.git', 'HEAD')) { + keys.add(Keys.lastCommit); + keys.add(Keys.currentBranch); + keys.add(Keys.aheadCount.all); + keys.add(Keys.behindCount.all); + continue; + } + + if (includes('.git', 'refs', 'heads')) { + keys.add(Keys.branches); + keys.add(Keys.lastCommit); + continue; + } + + if (includes('.git', 'refs', 'remotes')) { + keys.add(Keys.remotes); + continue; + } + + if (endsWith('.git', 'config')) { + keys.add(Keys.config.all); + keys.add(Keys.aheadCount.all); + keys.add(Keys.behindCount.all); + continue; + } + + // File change within the working directory + const relativePath = path.relative(this.workdir(), fullPath); + keys.add(Keys.changedFiles); + keys.add(Keys.filePatch.oneWith(relativePath, {staged: false})); + } + + if (keys.size > 0) { + this.cache.invalidate(Array.from(keys)); + this.didUpdate(); + } + } + refresh() { this.cache.clear(); this.didUpdate(); @@ -111,28 +148,28 @@ export default class Present extends State { // Staging and unstaging - @invalidate() + @invalidate(paths => Keys.cacheOperationKeys(paths)) stageFiles(paths) { return this.git().stageFiles(paths); } - @invalidate() + @invalidate(paths => Keys.cacheOperationKeys(paths)) unstageFiles(paths) { return this.git().unstageFiles(paths); } - @invalidate() + @invalidate(paths => Keys.cacheOperationKeys(paths)) stageFilesFromParentCommit(paths) { return this.git().unstageFiles(paths, 'HEAD~'); } - @invalidate() + @invalidate(filePatch => Keys.cacheOperationKeys([filePatch.getOldPath(), filePatch.getNewPath()])) applyPatchToIndex(filePatch) { const patchStr = filePatch.getHeaderString() + filePatch.toString(); return this.git().applyPatch(patchStr, {index: true}); } - @invalidate() + @invalidate(filePatch => Keys.workdirOperationKeys([filePatch.getOldPath(), filePatch.getNewPath()])) applyPatchToWorkdir(filePatch) { const patchStr = filePatch.getHeaderString() + filePatch.toString(); return this.git().applyPatch(patchStr); @@ -140,53 +177,82 @@ export default class Present extends State { // Committing - @invalidate() + @invalidate(() => [ + ...Keys.headOperationKeys(), + ...Keys.filePatch.eachWithOpts({staged: true}), + ]) commit(message, options) { return this.git().commit(formatCommitMessage(message), options); } // Merging - @invalidate() + @invalidate(() => [ + ...Keys.headOperationKeys(), + Keys.index.all, + ]) merge(branchName) { return this.git().merge(branchName); } - @invalidate() + @invalidate(() => [ + Keys.changedFiles, + Keys.stagedChangesSinceParentCommit, + Keys.filePatch.all, + Keys.index.all, + ]) 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() + @invalidate(filePath => [ + Keys.changedFiles, + Keys.stagedChangesSinceParentCommit, + ...Keys.filePatch.eachWithFileOpts([filePath], [{staged: false}, {staged: true}, {staged: true, amending: true}]), + Keys.index.oneWith(filePath), + ]) writeMergeConflictToIndex(filePath, commonBaseSha, oursSha, theirsSha) { return this.git().writeMergeConflictToIndex(filePath, commonBaseSha, oursSha, theirsSha); } // Checkout - @invalidate() + @invalidate(() => [ + Keys.stagedChangesSinceParentCommit, + Keys.lastCommit, + Keys.currentBranch, + Keys.index.all, + Keys.aheadCount.all, + Keys.behindCount.all, + ]) checkout(revision, options = {}) { return this.git().checkout(revision, options); } - @invalidate() + @invalidate(paths => [ + Keys.changedFiles, + Keys.stagedChangesSinceParentCommit, + ...paths.map(fileName => Keys.index.oneWith(fileName)), + ...Keys.filePatch.eachWithFileOpts(paths, [{staged: true}, {staged: true, amending: true}]), + ]) checkoutPathsAtRevision(paths, revision = 'HEAD') { return this.git().checkoutFiles(paths, revision); } // Remote interactions - @invalidate() + @invalidate(branchName => [ + Keys.aheadCount.oneWith(branchName), + Keys.behindCount.oneWith(branchName), + ]) async fetch(branchName) { const remote = await this.getRemoteForBranch(branchName); if (!remote.isPresent()) { @@ -195,7 +261,10 @@ export default class Present extends State { await this.git().fetch(remote.getName(), branchName); } - @invalidate() + @invalidate(() => [ + ...Keys.headOperationKeys(), + Keys.index.all, + ]) async pull(branchName) { const remote = await this.getRemoteForBranch(branchName); if (!remote.isPresent()) { @@ -204,7 +273,18 @@ export default class Present extends State { await this.git().pull(remote.getName(), branchName); } - @invalidate() + @invalidate((branchName, options = {}) => { + const keys = [ + Keys.aheadCount.oneWith(branchName), + Keys.behindCount.oneWith(branchName), + ]; + + if (options.setUpstream) { + keys.push(...Keys.config.eachWithSetting(`branch.${branchName}.remote`)); + } + + return keys; + }) async push(branchName, options) { const remote = await this.getRemoteForBranch(branchName); return this.git().push(remote.getNameOr('origin'), branchName, options); @@ -212,9 +292,9 @@ export default class Present extends State { // Configuration - @invalidate() - setConfig(option, value, options) { - return this.git().setConfig(option, value, options); + @invalidate(setting => Keys.config.eachWithSetting(setting)) + setConfig(setting, value, options) { + return this.git().setConfig(setting, value, options); } @invalidate() @@ -224,7 +304,6 @@ export default class Present extends State { // Direct blob interactions - @invalidate() createBlob(options) { return this.git().createBlob(options); } @@ -273,7 +352,10 @@ export default class Present extends State { return this.saveDiscardHistory(); } - @invalidate() + @invalidate(paths => [ + Keys.changedFiles, + ...paths.map(filePath => Keys.filePatch.oneWith(filePath, {staged: false})), + ]) async discardWorkDirChangesForPaths(paths) { const untrackedFiles = await this.git().getUntrackedFiles(); const [filesToRemove, filesToCheckout] = partition(paths, f => untrackedFiles.includes(f)); @@ -289,13 +371,13 @@ export default class Present extends State { // Index queries getStatusesForChangedFiles() { - return this.cache.getOrSet('changed-files', () => { + return this.cache.getOrSet(Keys.changedFiles, () => { return this.git().getStatusesForChangedFiles(); }); } getStagedChangesSinceParentCommit() { - return this.cache.getOrSet('staged-changes-since-parent-commit', async () => { + return this.cache.getOrSet(Keys.stagedChangesSinceParentCommit, async () => { try { const stagedFiles = await this.git().diffFileStatus({staged: true, target: 'HEAD~'}); return Object.keys(stagedFiles).map(filePath => ({filePath, status: stagedFiles[filePath]})); @@ -309,23 +391,8 @@ export default class Present extends State { }); } - 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 () => { + return this.cache.getOrSet(Keys.filePatch.oneWith(filePath, {staged, amending}), async () => { const options = {staged, amending}; if (amending) { options.baseCommit = 'HEAD~'; @@ -342,7 +409,7 @@ export default class Present extends State { } readFileFromIndex(filePath) { - return this.cache.getOrSet(`index:${filePath}`, () => { + return this.cache.getOrSet(Keys.index.oneWith(filePath), () => { return this.git().readFileFromIndex(filePath); }); } @@ -350,7 +417,7 @@ export default class Present extends State { // Commit access getLastCommit() { - return this.cache.getOrSet('last-commit', async () => { + return this.cache.getOrSet(Keys.lastCommit, async () => { const {sha, message, unbornRef} = await this.git().getHeadCommit(); return unbornRef ? Commit.createUnborn() : new Commit(sha, message); }); @@ -359,14 +426,14 @@ export default class Present extends State { // Branches getBranches() { - return this.cache.getOrSet('branches', async () => { + return this.cache.getOrSet(Keys.branches, async () => { const branchNames = await this.git().getBranches(); return branchNames.map(branchName => new Branch(branchName)); }); } getCurrentBranch() { - return this.cache.getOrSet('current-branch', async () => { + return this.cache.getOrSet(Keys.currentBranch, async () => { const {name, isDetached} = await this.git().getCurrentBranch(); return isDetached ? Branch.createDetached(name) : new Branch(name); }); @@ -385,27 +452,26 @@ export default class Present extends State { // Remotes getRemotes() { - return this.cache.getOrSet('remotes', async () => { + return this.cache.getOrSet(Keys.remotes, async () => { const remotesInfo = await this.git().getRemotes(); return remotesInfo.map(({name, url}) => new Remote(name, url)); }); } getAheadCount(branchName) { - return this.cache.getOrSet(`ahead-count:${branchName}`, () => { + return this.cache.getOrSet(Keys.aheadCount.oneWith(branchName), () => { return this.git().getAheadCount(branchName); }); } getBehindCount(branchName) { - return this.cache.getOrSet(`behind-count:${branchName}`, () => { + return this.cache.getOrSet(Keys.behindCount.oneWith(branchName), () => { return this.git().getBehindCount(branchName); }); } getConfig(option, {local} = {local: false}) { - const desc = local ? 'l' : ''; - return this.cache.getOrSet(`config:${desc}:${option}`, () => { + return this.cache.getOrSet(Keys.config.oneWith(option, {local}), () => { return this.git().getConfig(option, {local}); }); } @@ -413,7 +479,7 @@ export default class Present extends State { // Direct blob access getBlobContents(sha) { - return this.cache.getOrSet(`blob:${sha}`, () => { + return this.cache.getOrSet(Keys.blob(sha), () => { return this.git().getBlobContents(sha); }); } @@ -508,3 +574,209 @@ function buildFilePatchesFromRawDiffs(rawDiffs) { return new FilePatch(patch.oldPath, patch.newPath, patch.status, hunks); }); } + +class Cache { + constructor() { + this.storage = new Map(); + this.byGroup = new Map(); + } + + getOrSet(key, operation) { + const primary = key.getPrimary(); + const existing = this.storage.get(primary); + if (existing !== undefined) { + return existing; + } + + const created = operation(); + + this.storage.set(primary, created); + + const groups = key.getGroups(); + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + let groupSet = this.byGroup.get(group); + if (groupSet === undefined) { + groupSet = new Set(); + this.byGroup.set(group, groupSet); + } + groupSet.add(key); + } + + return created; + } + + invalidate(keys) { + for (let i = 0; i < keys.length; i++) { + keys[i].removeFromCache(this); + } + } + + keysInGroup(group) { + return this.byGroup.get(group) || []; + } + + removePrimary(primary) { + this.storage.delete(primary); + } + + removeFromGroup(group, key) { + const groupSet = this.byGroup.get(group); + groupSet && groupSet.delete(key); + } + + clear() { + this.storage.clear(); + this.byGroup.clear(); + } +} + +class CacheKey { + constructor(primary, groups = []) { + this.primary = primary; + this.groups = groups; + } + + getPrimary() { + return this.primary; + } + + getGroups() { + return this.groups; + } + + removeFromCache(cache, withoutGroup = null) { + cache.removePrimary(this.getPrimary()); + + const groups = this.getGroups(); + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + if (group === withoutGroup) { + continue; + } + + cache.removeFromGroup(group, this); + } + } +} + +class GroupKey { + constructor(group) { + this.group = group; + } + + removeFromCache(cache) { + for (const matchingKey of cache.keysInGroup(this.group)) { + matchingKey.removeFromCache(cache, this.group); + } + } +} + +const Keys = { + changedFiles: new CacheKey('changed-files'), + + stagedChangesSinceParentCommit: new CacheKey('staged-changes-since-parent-commit'), + + filePatch: { + _optKey: ({staged, amending}) => { + if (staged && amending) { + return 'a'; + } else if (staged) { + return 's'; + } else { + return 'u'; + } + }, + + oneWith: (fileName, options) => { // <-- Keys.filePatch + const optKey = Keys.filePatch._optKey(options); + return new CacheKey(`file-patch:${optKey}:${fileName}`, [ + 'file-patch', + `file-patch:${optKey}`, + ]); + }, + + eachWithFileOpts: (fileNames, opts) => { + const keys = []; + for (let i = 0; i < fileNames.length; i++) { + for (let j = 0; j < opts.length; j++) { + keys.push(Keys.filePatch.oneWith(fileNames[i], opts[j])); + } + } + return keys; + }, + + eachWithOpts: (...opts) => opts.map(opt => new GroupKey(`file-patch:${Keys.filePatch._optKey(opt)}`)), + + all: new GroupKey('file-patch'), + }, + + index: { + oneWith: fileName => new CacheKey(`index:${fileName}`, ['index']), + + all: new GroupKey('index'), + }, + + lastCommit: new CacheKey('last-commit'), + + branches: new CacheKey('branches'), + + currentBranch: new CacheKey('current-branch'), + + remotes: new CacheKey('remotes'), + + aheadCount: { + oneWith: branchName => new CacheKey(`ahead-count:${branchName}`, ['ahead-count']), + + all: new GroupKey('ahead-count'), + }, + + behindCount: { + oneWith: branchName => new CacheKey(`behind-count:${branchName}`, ['behind-count']), + + all: new GroupKey('behind-count'), + }, + + config: { + _optKey: options => (options.local ? 'l' : ''), + + oneWith: (setting, options) => { + const optKey = Keys.config._optKey(options); + return new CacheKey(`config:${optKey}:${setting}`, ['config', `config:${optKey}`]); + }, + + eachWithSetting: setting => [ + Keys.config.oneWith(setting, {local: true}), + Keys.config.oneWith(setting, {local: false}), + ], + + all: new GroupKey('config'), + }, + + blob: { + oneWith: sha => `blob:${sha}`, + }, + + // Common collections of keys and patterns for use with @invalidate(). + + workdirOperationKeys: fileNames => [ + Keys.changedFiles, + ...Keys.filePatch.eachWithFileOpts(fileNames, [{staged: false}]), + ], + + cacheOperationKeys: fileNames => [ + ...Keys.workdirOperationKeys(fileNames), + ...Keys.filePatch.eachWithFileOpts(fileNames, [{staged: true}, {staged: true, amending: true}]), + ...fileNames.map(Keys.index.oneWith), + Keys.stagedChangesSinceParentCommit, + ], + + headOperationKeys: () => [ + ...Keys.filePatch.eachWithOpts({staged: true, amending: true}), + Keys.changedFiles, + Keys.stagedChangesSinceParentCommit, + Keys.lastCommit, + Keys.aheadCount.all, + Keys.behindCount.all, + ], +}; diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index 2399610a3c..37133e2273 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -130,7 +130,7 @@ export default class State { } @shouldDelegate - observeFilesystemChange() { + observeFilesystemChange(events) { this.repository.refresh(); } @@ -309,11 +309,6 @@ export default class State { return Promise.resolve([]); } - @shouldDelegate - isPartiallyStaged(fileName) { - return Promise.resolve(false); - } - @shouldDelegate getFilePatchForPath(filePath, options = {}) { return Promise.resolve(null); diff --git a/lib/models/repository.js b/lib/models/repository.js index 684819dab8..2497ed4b72 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -171,6 +171,15 @@ export default class Repository { }); } + async isPartiallyStaged(fileName) { + const {unstagedFiles, stagedFiles} = await this.getStatusesForChangedFiles(); + const u = unstagedFiles[fileName]; + const s = stagedFiles[fileName]; + return (u === 'modified' && s === 'modified') || + (u === 'added' && s === 'modified') || + (u === 'modified' && s === 'deleted'); + } + async getRemoteForBranch(branchName) { const name = await this.getConfig(`branch.${branchName}.remote`); if (name === null) { @@ -181,7 +190,14 @@ export default class Repository { } async saveDiscardHistory() { + if (this.isDestroyed()) { + return; + } + const historySha = await this.createDiscardHistoryBlob(); + if (this.isDestroyed()) { + return; + } await this.setConfig('atomGithub.historySha', historySha); } } @@ -251,7 +267,6 @@ const delegates = [ 'getStatusesForChangedFiles', 'getStagedChangesSinceParentCommit', - 'isPartiallyStaged', 'getFilePatchForPath', 'readFileFromIndex', diff --git a/lib/models/workspace-change-observer.js b/lib/models/workspace-change-observer.js index b920d0e74e..29feb4754a 100644 --- a/lib/models/workspace-change-observer.js +++ b/lib/models/workspace-change-observer.js @@ -1,3 +1,4 @@ +import path from 'path'; import {CompositeDisposable, Disposable, Emitter} from 'event-kit'; import nsfw from 'nsfw'; @@ -74,7 +75,8 @@ export default class WorkspaceChangeObserver { gitDirectoryPath, events => { const filteredEvents = events.filter(e => { - return ['config', 'index', 'HEAD', 'MERGE_HEAD'].includes(e.file || e.newFile); + return ['config', 'index', 'HEAD', 'MERGE_HEAD'].includes(e.file || e.newFile) || + e.directory.includes(path.join('.git', 'refs')); }); if (filteredEvents.length) { this.logger.showEvents(filteredEvents); @@ -109,10 +111,10 @@ export default class WorkspaceChangeObserver { } } - activeRepositoryContainsPath(path) { + activeRepositoryContainsPath(filePath) { const repository = this.getRepository(); if (repository) { - return path.indexOf(repository.getWorkingDirectoryPath()) !== -1; + return filePath.indexOf(repository.getWorkingDirectoryPath()) !== -1; } else { return false; } diff --git a/package.json b/package.json index 785ac0655c..1126c8634e 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "eslint-plugin-promise": "^3.5.0", "eslint-plugin-react": "^6.7.1", "eslint-plugin-standard": "^3.0.1", + "lodash.isequal": "^4.5.0", "hock": "^1.3.2", "mkdirp": "^0.5.1", "mocha": "^3.2.0", diff --git a/test/fixtures/repo-multi-commits-files/a.txt b/test/fixtures/repo-multi-commits-files/a.txt new file mode 100644 index 0000000000..257cc5642c --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/a.txt @@ -0,0 +1 @@ +foo diff --git a/test/fixtures/repo-multi-commits-files/b.txt b/test/fixtures/repo-multi-commits-files/b.txt new file mode 100644 index 0000000000..75284a7422 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/b.txt @@ -0,0 +1,2 @@ +bar +bar 2 diff --git a/test/fixtures/repo-multi-commits-files/c.txt b/test/fixtures/repo-multi-commits-files/c.txt new file mode 100644 index 0000000000..17e8f63575 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/c.txt @@ -0,0 +1,2 @@ +baz +baz 2 diff --git a/test/fixtures/repo-multi-commits-files/dot-git/COMMIT_EDITMSG b/test/fixtures/repo-multi-commits-files/dot-git/COMMIT_EDITMSG new file mode 100644 index 0000000000..72818841d4 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/COMMIT_EDITMSG @@ -0,0 +1 @@ +Another commit diff --git a/test/fixtures/repo-multi-commits-files/dot-git/HEAD b/test/fixtures/repo-multi-commits-files/dot-git/HEAD new file mode 100644 index 0000000000..cb089cd89a --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/test/fixtures/repo-multi-commits-files/dot-git/MERGE_RR b/test/fixtures/repo-multi-commits-files/dot-git/MERGE_RR new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/repo-multi-commits-files/dot-git/config b/test/fixtures/repo-multi-commits-files/dot-git/config new file mode 100644 index 0000000000..6c9406b7d9 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/config @@ -0,0 +1,7 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true + ignorecase = true + precomposeunicode = true diff --git a/test/fixtures/repo-multi-commits-files/dot-git/description b/test/fixtures/repo-multi-commits-files/dot-git/description new file mode 100644 index 0000000000..498b267a8c --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/test/fixtures/repo-multi-commits-files/dot-git/hooks/applypatch-msg.sample b/test/fixtures/repo-multi-commits-files/dot-git/hooks/applypatch-msg.sample new file mode 100755 index 0000000000..a5d7b84a67 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/test/fixtures/repo-multi-commits-files/dot-git/hooks/commit-msg.sample b/test/fixtures/repo-multi-commits-files/dot-git/hooks/commit-msg.sample new file mode 100755 index 0000000000..b58d1184a9 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/test/fixtures/repo-multi-commits-files/dot-git/hooks/post-update.sample b/test/fixtures/repo-multi-commits-files/dot-git/hooks/post-update.sample new file mode 100755 index 0000000000..ec17ec1939 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/test/fixtures/repo-multi-commits-files/dot-git/hooks/pre-applypatch.sample b/test/fixtures/repo-multi-commits-files/dot-git/hooks/pre-applypatch.sample new file mode 100755 index 0000000000..4142082bcb --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/test/fixtures/repo-multi-commits-files/dot-git/hooks/pre-commit.sample b/test/fixtures/repo-multi-commits-files/dot-git/hooks/pre-commit.sample new file mode 100755 index 0000000000..68d62d5446 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=4b825dc642cb6eb9a060e54bf8d69288fbee4904 +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/test/fixtures/repo-multi-commits-files/dot-git/hooks/pre-push.sample b/test/fixtures/repo-multi-commits-files/dot-git/hooks/pre-push.sample new file mode 100755 index 0000000000..6187dbf439 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +z40=0000000000000000000000000000000000000000 + +while read local_ref local_sha remote_ref remote_sha +do + if [ "$local_sha" = $z40 ] + then + # Handle delete + : + else + if [ "$remote_sha" = $z40 ] + then + # New branch, examine all commits + range="$local_sha" + else + # Update to existing branch, examine new commits + range="$remote_sha..$local_sha" + fi + + # Check for WIP commit + commit=`git rev-list -n 1 --grep '^WIP' "$range"` + if [ -n "$commit" ] + then + echo >&2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/test/fixtures/repo-multi-commits-files/dot-git/hooks/pre-rebase.sample b/test/fixtures/repo-multi-commits-files/dot-git/hooks/pre-rebase.sample new file mode 100755 index 0000000000..9773ed4cb2 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up-to-date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +exit 0 + +################################################################ + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". diff --git a/test/fixtures/repo-multi-commits-files/dot-git/hooks/prepare-commit-msg.sample b/test/fixtures/repo-multi-commits-files/dot-git/hooks/prepare-commit-msg.sample new file mode 100755 index 0000000000..f093a02ec4 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/hooks/prepare-commit-msg.sample @@ -0,0 +1,36 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first comments out the +# "Conflicts:" part of a merge commit. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +case "$2,$3" in + merge,) + /usr/bin/perl -i.bak -ne 's/^/# /, s/^# #/#/ if /^Conflicts/ .. /#/; print' "$1" ;; + +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$1" ;; + + *) ;; +esac + +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" diff --git a/test/fixtures/repo-multi-commits-files/dot-git/hooks/update.sample b/test/fixtures/repo-multi-commits-files/dot-git/hooks/update.sample new file mode 100755 index 0000000000..d84758373d --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/hooks/update.sample @@ -0,0 +1,128 @@ +#!/bin/sh +# +# An example hook script to blocks unannotated tags from entering. +# Called by "git receive-pack" with arguments: refname sha1-old sha1-new +# +# To enable this hook, rename this file to "update". +# +# Config +# ------ +# hooks.allowunannotated +# This boolean sets whether unannotated tags will be allowed into the +# repository. By default they won't be. +# hooks.allowdeletetag +# This boolean sets whether deleting tags will be allowed in the +# repository. By default they won't be. +# hooks.allowmodifytag +# This boolean sets whether a tag may be modified after creation. By default +# it won't be. +# hooks.allowdeletebranch +# This boolean sets whether deleting branches will be allowed in the +# repository. By default they won't be. +# hooks.denycreatebranch +# This boolean sets whether remotely creating branches will be denied +# in the repository. By default this is allowed. +# + +# --- Command line +refname="$1" +oldrev="$2" +newrev="$3" + +# --- Safety check +if [ -z "$GIT_DIR" ]; then + echo "Don't run this script from the command line." >&2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --bool hooks.allowunannotated) +allowdeletebranch=$(git config --bool hooks.allowdeletebranch) +denycreatebranch=$(git config --bool hooks.denycreatebranch) +allowdeletetag=$(git config --bool hooks.allowdeletetag) +allowmodifytag=$(git config --bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero="0000000000000000000000000000000000000000" +if [ "$newrev" = "$zero" ]; then + newrev_type=delete +else + newrev_type=$(git cat-file -t $newrev) +fi + +case "$refname","$newrev_type" in + refs/tags/*,commit) + # un-annotated tag + short_refname=${refname##refs/tags/} + if [ "$allowunannotated" != "true" ]; then + echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/test/fixtures/repo-multi-commits-files/dot-git/index b/test/fixtures/repo-multi-commits-files/dot-git/index new file mode 100644 index 0000000000..218cea2ac9 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/index differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/info/exclude b/test/fixtures/repo-multi-commits-files/dot-git/info/exclude new file mode 100644 index 0000000000..a5196d1be8 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/logs/HEAD b/test/fixtures/repo-multi-commits-files/dot-git/logs/HEAD new file mode 100644 index 0000000000..a8615335ff --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/logs/HEAD @@ -0,0 +1,6 @@ +0000000000000000000000000000000000000000 f44d312c2f07c2b69ae48bdc2d1e2f125df388d1 Nathan Sobo 1466116715 -0600 commit (initial): Initial commit +f44d312c2f07c2b69ae48bdc2d1e2f125df388d1 9e6cfd1e2160f22c7c45259b720f0cc62c748c7c Antonio Scandurra 1466159114 +0200 commit (amend): Initial commit +9e6cfd1e2160f22c7c45259b720f0cc62c748c7c 306c2d32d8fb911cb59da3bc2d600db0c7842fd9 Antonio Scandurra 1466604506 +0200 commit (amend): Initial commit +306c2d32d8fb911cb59da3bc2d600db0c7842fd9 66d11860af6d28eb38349ef83de475597cb0e8b4 Antonio Scandurra 1466604645 +0200 commit (amend): Initial commit +66d11860af6d28eb38349ef83de475597cb0e8b4 452fd1b53795bdc838e89448f2954a2c1125ac08 Ash Wilson 1493132091 -0400 commit: Another commit +452fd1b53795bdc838e89448f2954a2c1125ac08 8fd7c1d85c8c3c78ad9c8a21c328b1905df638c7 Ash Wilson 1493135077 -0400 commit (amend): Another commit diff --git a/test/fixtures/repo-multi-commits-files/dot-git/logs/refs/heads/master b/test/fixtures/repo-multi-commits-files/dot-git/logs/refs/heads/master new file mode 100644 index 0000000000..a8615335ff --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/logs/refs/heads/master @@ -0,0 +1,6 @@ +0000000000000000000000000000000000000000 f44d312c2f07c2b69ae48bdc2d1e2f125df388d1 Nathan Sobo 1466116715 -0600 commit (initial): Initial commit +f44d312c2f07c2b69ae48bdc2d1e2f125df388d1 9e6cfd1e2160f22c7c45259b720f0cc62c748c7c Antonio Scandurra 1466159114 +0200 commit (amend): Initial commit +9e6cfd1e2160f22c7c45259b720f0cc62c748c7c 306c2d32d8fb911cb59da3bc2d600db0c7842fd9 Antonio Scandurra 1466604506 +0200 commit (amend): Initial commit +306c2d32d8fb911cb59da3bc2d600db0c7842fd9 66d11860af6d28eb38349ef83de475597cb0e8b4 Antonio Scandurra 1466604645 +0200 commit (amend): Initial commit +66d11860af6d28eb38349ef83de475597cb0e8b4 452fd1b53795bdc838e89448f2954a2c1125ac08 Ash Wilson 1493132091 -0400 commit: Another commit +452fd1b53795bdc838e89448f2954a2c1125ac08 8fd7c1d85c8c3c78ad9c8a21c328b1905df638c7 Ash Wilson 1493135077 -0400 commit (amend): Another commit diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/10/0b0dec8c53a40e4de7714b2c612dad5fad9985 b/test/fixtures/repo-multi-commits-files/dot-git/objects/10/0b0dec8c53a40e4de7714b2c612dad5fad9985 new file mode 100644 index 0000000000..cd502a84d6 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/10/0b0dec8c53a40e4de7714b2c612dad5fad9985 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/17/e8f63575fbe35334c35f33c30d72134b791491 b/test/fixtures/repo-multi-commits-files/dot-git/objects/17/e8f63575fbe35334c35f33c30d72134b791491 new file mode 100644 index 0000000000..355de4a047 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/17/e8f63575fbe35334c35f33c30d72134b791491 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/25/7cc5642cb1a054f08cc83f2d943e56fd3ebe99 b/test/fixtures/repo-multi-commits-files/dot-git/objects/25/7cc5642cb1a054f08cc83f2d943e56fd3ebe99 new file mode 100644 index 0000000000..bdcf704c9e Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/25/7cc5642cb1a054f08cc83f2d943e56fd3ebe99 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/30/6c2d32d8fb911cb59da3bc2d600db0c7842fd9 b/test/fixtures/repo-multi-commits-files/dot-git/objects/30/6c2d32d8fb911cb59da3bc2d600db0c7842fd9 new file mode 100644 index 0000000000..fdf628945c Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/30/6c2d32d8fb911cb59da3bc2d600db0c7842fd9 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/36/dbd4548ee626e65fb4efc4ac0951d2e7d13ef9 b/test/fixtures/repo-multi-commits-files/dot-git/objects/36/dbd4548ee626e65fb4efc4ac0951d2e7d13ef9 new file mode 100644 index 0000000000..67c9a4615e Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/36/dbd4548ee626e65fb4efc4ac0951d2e7d13ef9 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/45/2fd1b53795bdc838e89448f2954a2c1125ac08 b/test/fixtures/repo-multi-commits-files/dot-git/objects/45/2fd1b53795bdc838e89448f2954a2c1125ac08 new file mode 100644 index 0000000000..db68cf8e96 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/45/2fd1b53795bdc838e89448f2954a2c1125ac08 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/57/16ca5987cbf97d6bb54920bea6adde242d87e6 b/test/fixtures/repo-multi-commits-files/dot-git/objects/57/16ca5987cbf97d6bb54920bea6adde242d87e6 new file mode 100644 index 0000000000..cfc3920fb3 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/57/16ca5987cbf97d6bb54920bea6adde242d87e6 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/5b/0f8e2fae7d9a5bda8a5a8e08b8d9ffac4ea5fb b/test/fixtures/repo-multi-commits-files/dot-git/objects/5b/0f8e2fae7d9a5bda8a5a8e08b8d9ffac4ea5fb new file mode 100644 index 0000000000..6c594d50f1 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/objects/5b/0f8e2fae7d9a5bda8a5a8e08b8d9ffac4ea5fb @@ -0,0 +1 @@ +x+)JMU06f040031QHÔ+©(aàæ}Ó¼„Ï÷y¡·N¢îÚøµ3[÷L › \ No newline at end of file diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/66/d11860af6d28eb38349ef83de475597cb0e8b4 b/test/fixtures/repo-multi-commits-files/dot-git/objects/66/d11860af6d28eb38349ef83de475597cb0e8b4 new file mode 100644 index 0000000000..d942cae5e5 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/objects/66/d11860af6d28eb38349ef83de475597cb0e8b4 @@ -0,0 +1,3 @@ +xeͱ ÆqgžâvS®˜4FG—>Áq%–ÄB‚×÷·ÑÑñû¿�ëºfúƒ´” žqf“zï"jã"{fLa`l²= s@ím²Ô’… +L5VËw\ŸY–-ž¸®0ÑŒ‡N£Öj¯û¡¤·"µä +S™·ÖFzwœó€Ú¡ópÔvÔ½dÉô‚Ÿ¤>¸Ý;± \ No newline at end of file diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/6a/ec5997172db87e793a8ed284cd37c377c32d59 b/test/fixtures/repo-multi-commits-files/dot-git/objects/6a/ec5997172db87e793a8ed284cd37c377c32d59 new file mode 100644 index 0000000000..9d95174d9c Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/6a/ec5997172db87e793a8ed284cd37c377c32d59 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/75/284a7422bfe59a00dada1941ec74d3c030a458 b/test/fixtures/repo-multi-commits-files/dot-git/objects/75/284a7422bfe59a00dada1941ec74d3c030a458 new file mode 100644 index 0000000000..a380304ca1 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/75/284a7422bfe59a00dada1941ec74d3c030a458 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/76/018072e09c5d31c8c6e3113b8aa0fe625195ca b/test/fixtures/repo-multi-commits-files/dot-git/objects/76/018072e09c5d31c8c6e3113b8aa0fe625195ca new file mode 100644 index 0000000000..f00b4ade89 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/76/018072e09c5d31c8c6e3113b8aa0fe625195ca differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/8f/d7c1d85c8c3c78ad9c8a21c328b1905df638c7 b/test/fixtures/repo-multi-commits-files/dot-git/objects/8f/d7c1d85c8c3c78ad9c8a21c328b1905df638c7 new file mode 100644 index 0000000000..e21db7c366 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/8f/d7c1d85c8c3c78ad9c8a21c328b1905df638c7 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/96/b005089c39ca199ba16a660f50903756e2abf7 b/test/fixtures/repo-multi-commits-files/dot-git/objects/96/b005089c39ca199ba16a660f50903756e2abf7 new file mode 100644 index 0000000000..6e9393954f Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/96/b005089c39ca199ba16a660f50903756e2abf7 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/9e/6cfd1e2160f22c7c45259b720f0cc62c748c7c b/test/fixtures/repo-multi-commits-files/dot-git/objects/9e/6cfd1e2160f22c7c45259b720f0cc62c748c7c new file mode 100644 index 0000000000..395bc9ea6e Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/9e/6cfd1e2160f22c7c45259b720f0cc62c748c7c differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/a2/6e7c03dba9e3e17b0091a95eff67e663b6093a b/test/fixtures/repo-multi-commits-files/dot-git/objects/a2/6e7c03dba9e3e17b0091a95eff67e663b6093a new file mode 100644 index 0000000000..9432c3ab82 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/a2/6e7c03dba9e3e17b0091a95eff67e663b6093a differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/b1/5a256b50a5de488c3af81a168c2eb3cf8056ab b/test/fixtures/repo-multi-commits-files/dot-git/objects/b1/5a256b50a5de488c3af81a168c2eb3cf8056ab new file mode 100644 index 0000000000..c59a2079e4 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/b1/5a256b50a5de488c3af81a168c2eb3cf8056ab differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/bd/ee5a3a30f52329c051e63964dbb4294a9cec7b b/test/fixtures/repo-multi-commits-files/dot-git/objects/bd/ee5a3a30f52329c051e63964dbb4294a9cec7b new file mode 100644 index 0000000000..abcf7565a1 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/bd/ee5a3a30f52329c051e63964dbb4294a9cec7b differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/bf/951bbda0483fbdbaa50e9db9c51ffb7d2f78f1 b/test/fixtures/repo-multi-commits-files/dot-git/objects/bf/951bbda0483fbdbaa50e9db9c51ffb7d2f78f1 new file mode 100644 index 0000000000..ec1f740905 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/bf/951bbda0483fbdbaa50e9db9c51ffb7d2f78f1 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/c0/5164eba3a17e452dd77dd67c93032295860891 b/test/fixtures/repo-multi-commits-files/dot-git/objects/c0/5164eba3a17e452dd77dd67c93032295860891 new file mode 100644 index 0000000000..2cf183e1d8 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/c0/5164eba3a17e452dd77dd67c93032295860891 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/ea/696dc1e354b6014bc5cc6e87c282e23a7d8605 b/test/fixtures/repo-multi-commits-files/dot-git/objects/ea/696dc1e354b6014bc5cc6e87c282e23a7d8605 new file mode 100644 index 0000000000..0960a59fb0 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/ea/696dc1e354b6014bc5cc6e87c282e23a7d8605 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/ea/e629e0574add4da0f67056fd3ec128c18b2c47 b/test/fixtures/repo-multi-commits-files/dot-git/objects/ea/e629e0574add4da0f67056fd3ec128c18b2c47 new file mode 100644 index 0000000000..4013fd7e59 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/ea/e629e0574add4da0f67056fd3ec128c18b2c47 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/f3/d6e209f77d0152610976d0bab77573c92c252c b/test/fixtures/repo-multi-commits-files/dot-git/objects/f3/d6e209f77d0152610976d0bab77573c92c252c new file mode 100644 index 0000000000..39d3c83077 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/f3/d6e209f77d0152610976d0bab77573c92c252c differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/f4/4d312c2f07c2b69ae48bdc2d1e2f125df388d1 b/test/fixtures/repo-multi-commits-files/dot-git/objects/f4/4d312c2f07c2b69ae48bdc2d1e2f125df388d1 new file mode 100644 index 0000000000..2a4e5659b1 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/objects/f4/4d312c2f07c2b69ae48bdc2d1e2f125df388d1 @@ -0,0 +1,2 @@ +x��K +1]ç}%­“dƒ¸uãÆtçc&�¡çþz—¯ ê…ÑZ@粦œ½AæHzš/™#�|d æÌ.ž³›3*Ú¤Œ$…:<XúwÜ^UÊÆ§0Úp²Ñ:4pÔVkµÓýPÒªº÷*•Þðk¨ÿ:º \ No newline at end of file diff --git a/test/fixtures/repo-multi-commits-files/dot-git/objects/fe/0631213ec1a4a90900d82883fd88b8d8622026 b/test/fixtures/repo-multi-commits-files/dot-git/objects/fe/0631213ec1a4a90900d82883fd88b8d8622026 new file mode 100644 index 0000000000..dd0dd9a142 Binary files /dev/null and b/test/fixtures/repo-multi-commits-files/dot-git/objects/fe/0631213ec1a4a90900d82883fd88b8d8622026 differ diff --git a/test/fixtures/repo-multi-commits-files/dot-git/refs/heads/master b/test/fixtures/repo-multi-commits-files/dot-git/refs/heads/master new file mode 100644 index 0000000000..ff0be8ace2 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/dot-git/refs/heads/master @@ -0,0 +1 @@ +8fd7c1d85c8c3c78ad9c8a21c328b1905df638c7 diff --git a/test/fixtures/repo-multi-commits-files/subdir-1/a.txt b/test/fixtures/repo-multi-commits-files/subdir-1/a.txt new file mode 100644 index 0000000000..257cc5642c --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/subdir-1/a.txt @@ -0,0 +1 @@ +foo diff --git a/test/fixtures/repo-multi-commits-files/subdir-1/b.txt b/test/fixtures/repo-multi-commits-files/subdir-1/b.txt new file mode 100644 index 0000000000..c05164eba3 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/subdir-1/b.txt @@ -0,0 +1,2 @@ +bar +subbar 2 diff --git a/test/fixtures/repo-multi-commits-files/subdir-1/c.txt b/test/fixtures/repo-multi-commits-files/subdir-1/c.txt new file mode 100644 index 0000000000..bdee5a3a30 --- /dev/null +++ b/test/fixtures/repo-multi-commits-files/subdir-1/c.txt @@ -0,0 +1,2 @@ +baz +subbaz 2 diff --git a/test/github-package.test.js b/test/github-package.test.js index 8c70ba1a28..6fbef3f801 100644 --- a/test/github-package.test.js +++ b/test/github-package.test.js @@ -552,8 +552,8 @@ describe('GithubPackage', function() { repository1 = contextPool.getContext(workdirPath1).getRepository(); repository2 = contextPool.getContext(workdirPath2).getRepository(); - sinon.stub(repository1, 'refresh'); - sinon.stub(repository2, 'refresh'); + sinon.stub(repository1, 'observeFilesystemChange'); + sinon.stub(repository2, 'observeFilesystemChange'); }); it('refreshes the appropriate Repository and Atom GitRepository when a file is changed in workspace 1', async function() { @@ -563,7 +563,7 @@ describe('GithubPackage', function() { fs.writeFileSync(path.join(workdirPath1, 'a.txt'), 'some changes', 'utf8'); - await assert.async.isTrue(repository1.refresh.called); + await assert.async.isTrue(repository1.observeFilesystemChange.called); await assert.async.isTrue(atomGitRepository1.refreshStatus.called); }); @@ -574,21 +574,21 @@ describe('GithubPackage', function() { fs.writeFileSync(path.join(workdirPath2, 'b.txt'), 'other changes', 'utf8'); - await assert.async.isTrue(repository2.refresh.called); + await assert.async.isTrue(repository2.observeFilesystemChange.called); await assert.async.isTrue(atomGitRepository2.refreshStatus.called); }); it('refreshes the appropriate Repository and Atom GitRepository when a commit is made in workspace 1', async function() { await repository1.git.exec(['commit', '-am', 'commit in repository1']); - await assert.async.isTrue(repository1.refresh.called); + await assert.async.isTrue(repository1.observeFilesystemChange.called); await assert.async.isTrue(atomGitRepository1.refreshStatus.called); }); it('refreshes the appropriate Repository and Atom GitRepository when a commit is made in workspace 2', async function() { await repository2.git.exec(['commit', '-am', 'commit in repository2']); - await assert.async.isTrue(repository2.refresh.called); + await assert.async.isTrue(repository2.observeFilesystemChange.called); await assert.async.isTrue(atomGitRepository2.refreshStatus.called); }); }); diff --git a/test/models/repository.test.js b/test/models/repository.test.js index 075d80c070..decc4f3c4b 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -3,11 +3,19 @@ import path from 'path'; import dedent from 'dedent-js'; import temp from 'temp'; import util from 'util'; +import compareSets from 'compare-sets'; +import isEqual from 'lodash.isequal'; +import {CompositeDisposable, Disposable} from 'event-kit'; import Repository from '../../lib/models/repository'; import {expectedDelegates} from '../../lib/models/repository-states'; +import FileSystemChangeObserver from '../../lib/models/file-system-change-observer'; -import {cloneRepository, assertDeepPropertyVals, setUpLocalAndRemoteRepositories, getHeadCommitOnRemote, assertEqualSortedArraysByKey} from '../helpers'; +import { + cloneRepository, setUpLocalAndRemoteRepositories, getHeadCommitOnRemote, + assertDeepPropertyVals, assertEqualSortedArraysByKey, +} from '../helpers'; +import {writeFile} from '../../lib/helpers'; describe('Repository', function() { it('delegates all state methods', function() { @@ -407,6 +415,8 @@ describe('Repository', function() { assert.deepEqual((await repo.getLastCommit()).getMessage(), 'Make a commit'); }); + + it('clears the stored resolution progress'); }); describe('fetch(branchName)', function() { @@ -830,4 +840,661 @@ describe('Repository', function() { await repo2.getLoadPromise(); }); }); + + describe('cache invalidation', function() { + function getCacheReaderMethods(options) { + const repository = options.repository; + const calls = new Map(); + + calls.set('getStatusesForChangedFiles', () => repository.getStatusesForChangedFiles()); + calls.set('getStagedChangesSinceParentCommit', () => repository.getStagedChangesSinceParentCommit()); + calls.set('getLastCommit', () => repository.getLastCommit()); + calls.set('getBranches', () => repository.getBranches()); + calls.set('getCurrentBranch', () => repository.getCurrentBranch()); + calls.set('getRemotes', () => repository.getRemotes()); + + const withFile = fileName => { + calls.set( + `getFilePatchForPath {unstaged} ${fileName}`, + () => repository.getFilePatchForPath(fileName, {staged: false}), + ); + calls.set( + `getFilePatchForPath {staged} ${fileName}`, + () => repository.getFilePatchForPath(fileName, {staged: true}), + ); + calls.set( + `getFilePatchForPath {staged, amending} ${fileName}`, + () => repository.getFilePatchForPath(fileName, {staged: true, amending: true}), + ); + calls.set(`readFileFromIndex ${fileName}`, () => repository.readFileFromIndex(fileName)); + }; + + for (const fileName of (options.files || [])) { + withFile(fileName); + } + + const withBranch = (branchName, description) => { + calls.set(`getAheadCount ${description}`, () => repository.getAheadCount(branchName)); + calls.set(`getBehindCount ${description}`, () => repository.getBehindCount(branchName)); + }; + + if (options.changedBranch) { + withBranch(options.changedBranch); + } + if (options.unchangedBranch) { + withBranch(options.unchangedBranch); + } + + for (const optionName of (options.optionNames || [])) { + calls.set(`getConfig ${optionName}`, () => repository.getConfig(optionName)); + calls.set(`getConfig {local} ${optionName}`, () => repository.getConfig(optionName, {local: true})); + } + + return calls; + } + + /** + * Ensure that the correct cache keys are invalidated by a Repository operation. + */ + async function assertCorrectInvalidation(options, operation) { + const methods = getCacheReaderMethods(options); + for (const opName of (options.skip || [])) { + methods.delete(opName); + } + + const record = () => { + const results = new Map(); + for (const [name, call] of methods) { + results.set(name, call()); + } + return results; + }; + + const invalidatedKeys = (mapA, mapB) => { + const allKeys = Array.from(mapA.keys()); + assert.sameMembers(allKeys, Array.from(mapB.keys())); + + return new Set( + allKeys.filter(key => mapA.get(key) !== mapB.get(key)), + ); + }; + + const changedKeys = async (mapA, mapB) => { + const allKeys = Array.from(mapA.keys()); + assert.sameMembers(allKeys, Array.from(mapB.keys())); + + const syncResults = await Promise.all( + allKeys.map(async key => { + return { + key, + aSync: await mapA.get(key).catch(e => e), + bSync: await mapB.get(key).catch(e => e), + }; + }), + ); + + return new Set( + syncResults + .filter(({aSync, bSync}) => !isEqual(aSync, bSync)) + .map(({key}) => key), + ); + }; + + const before = record(); + await operation(); + const cached = record(); + + options.repository.state.cache.clear(); + const after = await record(); + + const expected = await changedKeys(before, after); + const actual = invalidatedKeys(before, cached); + const {added, removed} = compareSets(expected, actual); + + if (options.expected) { + for (const opName of options.expected) { + added.delete(opName); + } + } + + /* eslint-disable no-console */ + if (added.size > 0 && (options.strict || options.verbose)) { + console.log('These cached method results were invalidated, but should not have been:'); + + for (const key of added) { + console.log(` ${key}:`); + console.log(' before:', before.get(key)); + console.log(' cached:', cached.get(key)); + console.log(' after:', after.get(key)); + } + } + + if (removed.size > 0) { + console.log('These cached method results should have been invalidated, but were not:'); + for (const key of removed) { + console.log(` ${key}:`); + console.log(' before:', before.get(key)); + console.log(' cached:', cached.get(key)); + console.log(' after:', after.get(key)); + } + } + /* eslint-enable no-console */ + + if (options.strict) { + assert.isTrue(added.size === 0 && removed.size === 0, 'invalidated different method results'); + } else { + assert.isTrue(removed.size === 0, 'bzzzt, inadequate cache busting detected'); + } + } + + describe('from method calls', function() { + it('when staging files', async function() { + const workdir = await cloneRepository('multi-commits-files'); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + + const changedFile = 'a.txt'; + const unchangedFile = 'b.txt'; + + await writeFile(path.join(workdir, changedFile), 'bar\nbar-1\n'); + + const files = [changedFile, unchangedFile]; + await assertCorrectInvalidation({repository, files}, async () => { + await repository.stageFiles([changedFile]); + }); + }); + + it('when unstaging files', async function() { + const workdir = await cloneRepository('multi-commits-files'); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + + const changedFile = 'a.txt'; + const unchangedFile = 'b.txt'; + + await writeFile(path.join(workdir, changedFile), 'bar\nbaz\n'); + await repository.stageFiles([changedFile]); + + const files = [changedFile, unchangedFile]; + await assertCorrectInvalidation({repository, files}, async () => { + await repository.unstageFiles([changedFile]); + }); + }); + + it('when staging files from a parent commit', async function() { + const workdir = await cloneRepository('multi-commits-files'); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + + const changedFile = 'a.txt'; + const unchangedFile = 'b.txt'; + + await writeFile(path.join(workdir, changedFile), 'bar\nbaz\n'); + await repository.stageFiles([changedFile]); + + const files = [changedFile, unchangedFile]; + await assertCorrectInvalidation({repository, files}, async () => { + await repository.stageFilesFromParentCommit([changedFile]); + }); + }); + + it('when applying a patch to the index', async function() { + const workdir = await cloneRepository('multi-commits-files'); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + + const changedFile = 'a.txt'; + const unchangedFile = 'b.txt'; + + await writeFile(path.join(workdir, changedFile), 'foo\nfoo-1\n'); + const patch = await repository.getFilePatchForPath(changedFile); + await writeFile(path.join(workdir, changedFile), 'foo\nfoo-1\nfoo-2\n'); + + const files = [changedFile, unchangedFile]; + await assertCorrectInvalidation({repository, files}, async () => { + await repository.applyPatchToIndex(patch); + }); + }); + + it('when applying a patch to the working directory', async function() { + const workdir = await cloneRepository('multi-commits-files'); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + + const changedFile = 'a.txt'; + const unchangedFile = 'b.txt'; + + await writeFile(path.join(workdir, changedFile), 'foo\nfoo-1\n'); + const patch = (await repository.getFilePatchForPath(changedFile)).getUnstagePatch(); + + const files = [changedFile, unchangedFile]; + await assertCorrectInvalidation({repository, files}, async () => { + await repository.applyPatchToWorkdir(patch); + }); + }); + + it('when committing', async function() { + const workdir = await cloneRepository('multi-commits-files'); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + + const changedFile = 'b.txt'; + await writeFile(path.join(workdir, changedFile), 'foo\nfoo-1\nfoo-2\n'); + await repository.stageFiles([changedFile]); + + const files = [changedFile]; + await assertCorrectInvalidation({repository, files}, async () => { + await repository.commit('message'); + }); + }); + + it('when merging', async function() { + const workdir = await cloneRepository('merge-conflict'); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + + const skip = [ + 'readFileFromIndex modified-on-both-ours.txt', + ]; + const files = ['modified-on-both-ours.txt']; + await assertCorrectInvalidation({repository, files, skip}, async () => { + await assert.isRejected(repository.merge('origin/branch')); + }); + }); + + it('when aborting a merge', async function() { + const workdir = await cloneRepository('merge-conflict'); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + await assert.isRejected(repository.merge('origin/branch')); + + const stagedFile = 'modified-on-both-ours.txt'; + await repository.stageFiles([stagedFile]); + + const files = [stagedFile]; + await assertCorrectInvalidation({repository, files}, async () => { + await repository.abortMerge(); + }); + }); + + it('when writing a merge conflict to the index', async function() { + const workdir = await cloneRepository('three-files'); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + + const fullPath = path.join(workdir, 'a.txt'); + await writeFile(fullPath, 'qux\nfoo\nbar\n'); + await repository.git.exec(['update-index', '--chmod=+x', 'a.txt']); + + const commonBaseSha = '7f95a814cbd9b366c5dedb6d812536dfef2fffb7'; + const oursSha = '95d4c5b7b96b3eb0853f586576dc8b5ac54837e0'; + const theirsSha = '5da808cc8998a762ec2761f8be2338617f8f12d9'; + + const files = ['a.txt']; + const skip = [ + 'getFilePatchForPath {unstaged} a.txt', + 'getFilePatchForPath {staged} a.txt', + 'getFilePatchForPath {staged, amending} a.txt', + 'readFileFromIndex a.txt', + ]; + await assertCorrectInvalidation({repository, files, skip}, async () => { + await repository.writeMergeConflictToIndex('a.txt', commonBaseSha, oursSha, theirsSha); + }); + }); + + it('when checking out a revision', async function() { + const workdir = await cloneRepository('multi-commits-files'); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + + const skip = [ + 'getFilePatchForPath {staged, amending} b.txt', + ]; + const files = ['b.txt']; + await assertCorrectInvalidation({repository, files, skip}, async () => { + await repository.checkout('HEAD^'); + }); + }); + + it('when checking out paths', async function() { + const workdir = await cloneRepository('multi-commits-files'); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + + const changedFile = 'b.txt'; + const unchangedFile = 'c.txt'; + + const files = [changedFile, unchangedFile]; + await assertCorrectInvalidation({repository, files}, async () => { + await repository.checkoutPathsAtRevision([changedFile], 'HEAD^'); + }); + }); + + it('when fetching', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories(); + const repository = new Repository(localRepoPath); + await repository.getLoadPromise(); + + await repository.commit('wat', {allowEmpty: true}); + await repository.commit('huh', {allowEmpty: true}); + + await assertCorrectInvalidation({repository}, async () => { + await repository.fetch('master'); + }); + }); + + it('when pulling', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); + const repository = new Repository(localRepoPath); + await repository.getLoadPromise(); + + await writeFile(path.join(localRepoPath, 'new-file.txt'), 'one\n'); + await repository.stageFiles(['new-file.txt']); + await repository.commit('wat'); + + const files = ['new-file.txt', 'file.txt']; + await assertCorrectInvalidation({repository, files}, async () => { + await repository.pull('master'); + }); + }); + + it('when pushing', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories(); + const repository = new Repository(localRepoPath); + await repository.getLoadPromise(); + + await writeFile(path.join(localRepoPath, 'new-file.txt'), 'one\n'); + await repository.stageFiles(['new-file.txt']); + await repository.commit('wat'); + + const files = ['new-file.txt', 'file.txt']; + await assertCorrectInvalidation({repository, files}, async () => { + await repository.push('master'); + }); + }); + + it('when setting a config option', async function() { + const workdir = await cloneRepository('three-files'); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + + const optionNames = ['core.editor', 'color.ui']; + await assertCorrectInvalidation({repository, optionNames}, async () => { + await repository.setConfig('core.editor', 'atom --wait #obvs'); + }); + }); + + it('when discarding working directory changes', async function() { + const workdir = await cloneRepository('multi-commits-files'); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + + await Promise.all([ + writeFile(path.join(workdir, 'a.txt'), 'aaa\n'), + writeFile(path.join(workdir, 'c.txt'), 'baz\n'), + ]); + + const files = ['a.txt', 'b.txt', 'c.txt']; + await assertCorrectInvalidation({repository, files}, async () => { + await repository.discardWorkDirChangesForPaths(['a.txt', 'c.txt']); + }); + }); + }); + + describe('from filesystem events', function() { + let workdir, sub; + let observedEvents, eventCallback; + + async function wireUpObserver(fixtureName = 'multi-commits-files', existingWorkdir = null) { + observedEvents = []; + eventCallback = () => {}; + + workdir = existingWorkdir || await cloneRepository(fixtureName); + const repository = new Repository(workdir); + await repository.getLoadPromise(); + + const observer = new FileSystemChangeObserver(repository); + + sub = new CompositeDisposable( + new Disposable(async () => { + await observer.destroy(); + repository.destroy(); + }), + ); + + sub.add(observer.onDidChange(events => { + repository.observeFilesystemChange(events); + observedEvents.push(...events); + eventCallback(); + })); + + return {repository, observer}; + } + + function expectEvents(...fileNames) { + return new Promise((resolve, reject) => { + eventCallback = () => { + const observedFileNames = new Set(observedEvents.map(event => event.file || event.newFile)); + if (fileNames.every(fileName => observedFileNames.has(fileName))) { + resolve(); + } + }; + + if (observedEvents.length > 0) { + eventCallback(); + } + }); + } + + afterEach(function() { + sub && sub.dispose(); + }); + + it('when staging files', async function() { + const {repository, observer} = await wireUpObserver(); + + await writeFile(path.join(workdir, 'a.txt'), 'boop\n'); + + const files = ['a.txt']; + await observer.start(); + await assertCorrectInvalidation({repository, files}, async () => { + await repository.git.stageFiles(['a.txt']); + await expectEvents('index'); + }); + }); + + it('when unstaging files', async function() { + const {repository, observer} = await wireUpObserver(); + + await writeFile(path.join(workdir, 'a.txt'), 'boop\n'); + await repository.git.stageFiles(['a.txt']); + + const files = ['a.txt']; + await observer.start(); + await assertCorrectInvalidation({repository, files}, async () => { + await repository.git.unstageFiles(['a.txt']); + await expectEvents('index'); + }); + }); + + it('when staging files from a parent commit', async function() { + const {repository, observer} = await wireUpObserver(); + + const files = ['a.txt']; + await observer.start(); + await assertCorrectInvalidation({repository, files}, async () => { + await repository.git.unstageFiles(['a.txt'], 'HEAD~'); + await expectEvents('index'); + }); + }); + + it('when applying a patch to the index', async function() { + const {repository, observer} = await wireUpObserver(); + + await writeFile(path.join(workdir, 'a.txt'), 'boop\n'); + const patch = await repository.getFilePatchForPath('a.txt'); + + const files = ['a.txt']; + await observer.start(); + await assertCorrectInvalidation({repository, files}, async () => { + await repository.git.applyPatch(patch.getHeaderString() + patch.toString(), {index: true}); + await expectEvents('index'); + }); + }); + + it('when applying a patch to the working directory', async function() { + const {repository, observer} = await wireUpObserver(); + + await writeFile(path.join(workdir, 'a.txt'), 'boop\n'); + const patch = (await repository.getFilePatchForPath('a.txt')).getUnstagePatch(); + + const files = ['a.txt']; + await observer.start(); + await assertCorrectInvalidation({repository, files}, async () => { + await repository.git.applyPatch(patch.getHeaderString() + patch.toString()); + await expectEvents('a.txt'); + }); + }); + + it('when committing', async function() { + const {repository, observer} = await wireUpObserver(); + + await writeFile(path.join(workdir, 'a.txt'), 'boop\n'); + await repository.stageFiles(['a.txt']); + + const files = ['a.txt']; + await observer.start(); + await assertCorrectInvalidation({repository, files}, async () => { + await repository.git.commit('boop your snoot'); + await expectEvents('index', 'master'); + }); + }); + + it('when merging', async function() { + const {repository, observer} = await wireUpObserver('merge-conflict'); + + const skip = [ + 'readFileFromIndex modified-on-both-ours.txt', + ]; + const files = ['modified-on-both-ours.txt']; + await observer.start(); + await assertCorrectInvalidation({repository, files, skip}, async () => { + await assert.isRejected(repository.git.merge('origin/branch')); + await expectEvents('index', 'modified-on-both-ours.txt', 'MERGE_HEAD'); + }); + }); + + it('when aborting a merge', async function() { + const {repository, observer} = await wireUpObserver('merge-conflict'); + await assert.isRejected(repository.merge('origin/branch')); + + const skip = [ + 'readFileFromIndex modified-on-both-ours.txt', + ]; + const files = ['modified-on-both-ours.txt']; + await observer.start(); + await assertCorrectInvalidation({repository, files, skip}, async () => { + await repository.git.abortMerge(); + await expectEvents('index', 'modified-on-both-ours.txt', 'MERGE_HEAD', 'HEAD'); + }); + }); + + it('when checking out a revision', async function() { + const {repository, observer} = await wireUpObserver(); + + const skip = [ + 'getFilePatchForPath {staged, amending} b.txt', + ]; + const files = ['b.txt']; + await observer.start(); + await assertCorrectInvalidation({repository, files, skip}, async () => { + await repository.git.checkout('HEAD^'); + await expectEvents('index', 'HEAD', 'b.txt', 'c.txt'); + }); + }); + + it('when checking out paths', async function() { + const {repository, observer} = await wireUpObserver(); + + const files = ['b.txt', 'c.txt']; + + await observer.start(); + await assertCorrectInvalidation({repository, files}, async () => { + await repository.git.checkoutFiles(['b.txt'], 'HEAD^'); + await expectEvents('b.txt', 'index'); + }); + }); + + it('when fetching', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); + const {repository, observer} = await wireUpObserver(null, localRepoPath); + + await repository.commit('wat', {allowEmpty: true}); + await repository.commit('huh', {allowEmpty: true}); + + await observer.start(); + await assertCorrectInvalidation({repository}, async () => { + await repository.git.fetch('origin', 'master'); + await expectEvents('master'); + }); + }); + + it('when pulling', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories({remoteAhead: true}); + const {repository, observer} = await wireUpObserver(null, localRepoPath); + + await writeFile(path.join(localRepoPath, 'file.txt'), 'one\n'); + await repository.stageFiles(['file.txt']); + await repository.commit('wat'); + + const skip = [ + 'readFileFromIndex file.txt', + ]; + const files = ['file.txt']; + await observer.start(); + await assertCorrectInvalidation({repository, files, skip}, async () => { + await assert.isRejected(repository.git.pull('origin', 'master')); + await expectEvents('file.txt', 'master', 'MERGE_HEAD', 'index'); + }); + }); + + it('when pushing', async function() { + const {localRepoPath} = await setUpLocalAndRemoteRepositories(); + const {repository, observer} = await wireUpObserver(null, localRepoPath); + + await writeFile(path.join(localRepoPath, 'new-file.txt'), 'one\n'); + await repository.stageFiles(['new-file.txt']); + await repository.commit('wat'); + + const files = ['new-file.txt', 'file.txt']; + await observer.start(); + await assertCorrectInvalidation({repository, files}, async () => { + await repository.git.push('origin', 'master'); + await expectEvents('master'); + }); + }); + + it('when setting a config option', async function() { + const {repository, observer} = await wireUpObserver(); + + const optionNames = ['core.editor', 'color.ui']; + await observer.start(); + await assertCorrectInvalidation({repository, optionNames}, async () => { + await repository.git.setConfig('core.editor', 'ed # :trollface:'); + await expectEvents('config'); + }); + }); + + it('when changing files in the working directory', async function() { + const {repository, observer} = await wireUpObserver(); + + await observer.start(); + const files = ['b.txt']; + await assertCorrectInvalidation({repository, files}, async () => { + await writeFile(path.join(workdir, 'b.txt'), 'new contents\n'); + await expectEvents('b.txt'); + }); + }); + }); + }); });