Skip to content
This repository has been archived by the owner on Dec 15, 2022. It is now read-only.

Re-introduce commit message template #1756

Merged
merged 46 commits into from Nov 7, 2018
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
f5ccd16
Revert "Merge pull request #1754 from atom/revert-1713-feature/commit…
kuychaco Oct 23, 2018
0a24012
:art: getCommitMessageFromTemplate tests. Return null if config not set
kuychaco Oct 23, 2018
49a1d31
Restore state from last commit only if undo is successful
kuychaco Oct 24, 2018
3ebda2f
Pass events to `observeFilesystemChange` in order to use action property
kuychaco Oct 24, 2018
b6ef6d2
Update commit message after file system change for MERGE_HEAD or config
kuychaco Oct 24, 2018
d895691
:shirt:
kuychaco Oct 24, 2018
bcbbff8
Load initial commit message based on template or merge state
kuychaco Oct 24, 2018
b8e55ab
:art: variable message -> template
kuychaco Oct 24, 2018
ca10d13
Don't set merge message in controller, now handled in repository
kuychaco Oct 24, 2018
99fe542
Fix repo test now that we are passing events and not paths
kuychaco Oct 24, 2018
40b015e
WIP tests for CommitController
kuychaco Oct 24, 2018
ade7de8
Suppress repo update when typing commit message
kuychaco Oct 24, 2018
7c18ddb
Don't trim commit message template
kuychaco Oct 24, 2018
7a73138
:art: rename getCommitMessageFromTemplate to getCommitMessageTemplate
kuychaco Oct 24, 2018
b16525c
Enable commit button only if message contains non-comment lines
kuychaco Oct 24, 2018
b3cc2c3
Set cursor to beginning of commit message template
kuychaco Oct 24, 2018
5a7b2d4
Reset commit message after a successful abort triggered through the UI
kuychaco Oct 24, 2018
b3588ef
Add repository tests for commit message updating due to file system e…
kuychaco Oct 24, 2018
4a1525b
Trim message before checking if clean
kuychaco Oct 24, 2018
2c2857e
:fire: merge message tests in CommitController (now handled in repo)
kuychaco Oct 24, 2018
f780ad1
Fix WorkdirContext test
kuychaco Oct 24, 2018
5ee83db
Fix CommitController tests
kuychaco Oct 25, 2018
6e45f0c
:fire: commit-template test fixture
kuychaco Oct 25, 2018
56d9f5e
Clean up cloneRepository
kuychaco Oct 25, 2018
d145084
:fire: comment
kuychaco Oct 25, 2018
8a40a74
Fix and clean up GitStrategies tests
kuychaco Oct 25, 2018
63ffa69
add comment explaining regex
Oct 25, 2018
c15f1d0
cleanup some dead code
Oct 25, 2018
fd1d1cf
add comment about isValidMessage regex
Oct 25, 2018
3d94405
:fire: unused dependencies.
Oct 25, 2018
acf502c
Merge branch 'master' into pr-1756/atom/ku-tt-commit-msg-template
Oct 25, 2018
9b606ba
test merged changes + cleanup
Oct 26, 2018
6379f30
fix issue with setting cursor buffer position
Oct 26, 2018
d331372
Retry flakey file patch test
kuychaco Oct 26, 2018
13e7342
extract regex and give it a better name
Oct 29, 2018
218f193
rename getCommitMessageTemplate to fetchCommitMessageTemplate
Oct 29, 2018
e2cc423
extract filePathEndsWith into helper function
Oct 29, 2018
b3c9ec7
Extract wireUpObserver and expectEvents to test helpers file
kuychaco Oct 30, 2018
37fbb44
Access local config in spec mode
kuychaco Oct 30, 2018
6299ae2
Don't break snapshotting by accessing `atom` in global scope
kuychaco Oct 30, 2018
8aaf2ac
Test flakes
kuychaco Oct 30, 2018
e8e39db
Merge remote-tracking branch 'origin/master' into ku-tt-commit-msg-te…
kuychaco Oct 30, 2018
3e1cb40
Retry file-patch test flakes
kuychaco Oct 30, 2018
a87b3a4
handle case where template is unset
Nov 6, 2018
6f88f76
add unit test for unsetting commit template
Nov 6, 2018
4e400bd
address code review feedback.
Nov 6, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
19 changes: 4 additions & 15 deletions lib/controllers/commit-controller.js
Expand Up @@ -27,7 +27,6 @@ export default class CommitController extends React.Component {

repository: PropTypes.object.isRequired,
isMerging: PropTypes.bool.isRequired,
mergeMessage: PropTypes.string,
mergeConflictsExist: PropTypes.bool.isRequired,
stagedChangesExist: PropTypes.bool.isRequired,
lastCommit: PropTypes.object.isRequired,
Expand Down Expand Up @@ -73,10 +72,6 @@ export default class CommitController extends React.Component {
}
}),
);

if (this.props.isMerging && !this.getCommitMessage()) {
this.setCommitMessage(this.props.mergeMessage || '');
}
}

render() {
Expand Down Expand Up @@ -110,13 +105,6 @@ export default class CommitController extends React.Component {
);
}

// eslint-disable-next-line camelcase
UNSAFE_componentWillReceiveProps(nextProps) {
if (!this.props.isMerging && nextProps.isMerging && !this.getCommitMessage()) {
this.setCommitMessage(nextProps.mergeMessage || '');
}
}

componentWillUnmount() {
this.subscriptions.dispose();
}
Expand All @@ -135,10 +123,11 @@ export default class CommitController extends React.Component {
return this.props.commit(msg.trim(), {amend, coAuthors, verbatim});
}

setCommitMessage(message) {
setCommitMessage(message, options) {
if (!this.props.repository.isPresent()) { return; }
const changed = this.props.repository.getCommitMessage() !== message;
this.props.repository.setCommitMessage(message);
this.props.repository.setCommitMessage(message, options);
// ask @smashwilson -- why do we need to do this??
if (changed) { this.forceUpdate(); }
}

Expand All @@ -154,7 +143,7 @@ export default class CommitController extends React.Component {
if (!this.props.repository.isPresent()) {
return;
}
this.setCommitMessage(newMessage);
this.setCommitMessage(newMessage, {suppressUpdate: true});
}

getCommitMessageEditors() {
Expand Down
4 changes: 3 additions & 1 deletion lib/controllers/git-tab-controller.js
Expand Up @@ -275,13 +275,15 @@ export default class GitTabController extends React.Component {
const repo = this.props.repository;
const lastCommit = await repo.getLastCommit();
if (lastCommit.isUnbornRef()) { return null; }

await repo.undoLastCommit();
repo.setCommitMessage(lastCommit.getFullMessage());

const coAuthors = lastCommit.getCoAuthors().map(author =>
new Author(author.email, author.name));

this.updateSelectedCoAuthors(coAuthors);
return repo.undoLastCommit();
return null;
}

async abortMerge() {
Expand Down
3 changes: 2 additions & 1 deletion lib/get-repo-pipeline-manager.js
Expand Up @@ -210,7 +210,8 @@ export default function({confirm, notificationManager, workspace}) {
commitPipeline.addMiddleware('failed-to-commit-error', async (next, repository) => {
try {
const result = await next();
repository.setCommitMessage('');
const template = await repository.getCommitMessageTemplate();
repository.setCommitMessage(template || '');
annthurium marked this conversation as resolved.
Show resolved Hide resolved
destroyFilePatchPaneItems({onlyStaged: true}, workspace);
return result;
} catch (error) {
Expand Down
33 changes: 33 additions & 0 deletions lib/git-shell-out-strategy.js
Expand Up @@ -415,6 +415,39 @@ export default class GitShellOutStrategy {
return this.exec(args, {writeOperation: true});
}

async getCommitMessageTemplate() {
let templatePath = await this.getConfig('commit.template');
if (!templatePath) {
return null;
}

/**
* Get absolute path from git path
* https://git-scm.com/docs/git-config#git-config-pathname
*/
const homeDir = os.homedir();
// this regex attempts to get the specified user's home directory
// Ex: on Mac ~kuychaco/ is expanded to the specified user’s home directory (/Users/kuychaco)

// Regex translation:
// ^~ line starts with tilde
// ([^/]*)/ captures non-forwardslash characters before first slash
const regex = new RegExp('^~([^/]*)/');
annthurium marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason this is constructed with new RegExp() rather than as a regexp literal? Is it just for readability so you don't have to escape all of the / and get into leaning-toothpick-syndrome?

A more descriptive name than regex may be nice too 😄 How about something like tildeExpansionPattern?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We didn't really have a strong reason for preferring the regexp constructor over literals here. We went with what the original author had.

Out of curiosity, we looked to see if there was a performance difference. Answer: not really.
https://www.measurethat.net/Benchmarks/Show/1734/1/regexp-constructor-vs-literal

We did give it a more descriptive name, and moved it out of this function so as to avoid creating a new regex every time.

templatePath = templatePath.trim().replace(regex, (_, user) => {
// if no user is specified, fall back to using the home directory.
return `${user ? path.join(path.dirname(homeDir), user) : homeDir}/`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@smashwilson: can you test this on non-Mac operating systems? We want to make sure if a username is specified, it does the right thing for our Linux and or Windows users.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can do, and will report back 🙇

One thing that I can see right away is that this won't handle ~root/ correctly; on most *nix systems, that should expand to /root, not /users/root. In general the right thing to do on Unix-y systems is to use getpwnam(3) to read the definitive information, taking into account any weird local network drive setups and so on. Windows I'm less familiar with.

There are a few tilde-expansion libraries on npm, but unfortunately looking at their source it doesn't look like many of them handle all of this correctly either! I found one that uses etc-passwd to read its info, but I don't believe it has any accommodations for Windows. This may be a sign that this isn't important enough to hold up the 🚢 for 😉

});

if (!path.isAbsolute(templatePath)) {
templatePath = path.join(this.workingDir, templatePath);
kuychaco marked this conversation as resolved.
Show resolved Hide resolved
}

if (!await fileExists(templatePath)) {
throw new Error(`Invalid commit template path set in Git config: ${templatePath}`);
}
return await fs.readFile(templatePath, {encoding: 'utf8'});
}

unstageFiles(paths, commit = 'HEAD') {
if (paths.length === 0) { return Promise.resolve(null); }
const args = ['reset', commit, '--'].concat(paths.map(toGitPathSep));
Expand Down
79 changes: 76 additions & 3 deletions lib/models/repository-states/present.js
Expand Up @@ -41,20 +41,46 @@ export default class Present extends State {
this.operationStates = new OperationStates({didUpdate: this.didUpdate.bind(this)});

this.commitMessage = '';
this.commitMessageTemplate = null;
this.fetchInitialMessage();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if the commit message, commit message template, and merge message tracking belong more naturally in Repository rather than Present. Most methods in Present are intended to call a single git command with GitShellOutStrategy, manipulate the cache, and translate input and output to our model objects, while Repository methods implement higher-order composite behavior. Commit message derivation feels like a "composite" behavior to me 🤔

With that said: we already have a bunch of behavior in Repository and Present that blurs these lines, and it would not be trivial to move all of this there. So maybe we could leave this as a future refactoring as part of a broader effort to make the division among those abstraction layers clear.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool - should I open an issue to refactor this in the future? I'm on board with cleaning it up.


if (history) {
this.discardHistory.updateHistory(history);
}
}

setCommitMessage(message) {
setCommitMessage(message, {suppressUpdate} = {suppressUpdate: false}) {
this.commitMessage = message;
if (!suppressUpdate) {
this.didUpdate();
}
}

setCommitMessageTemplate(template) {
this.commitMessageTemplate = template;
}

async fetchInitialMessage() {
const mergeMessage = await this.repository.getMergeMessage();
const template = await this.getCommitMessageTemplate();
if (template) {
this.commitMessageTemplate = template;
}
if (mergeMessage) {
this.setCommitMessage(mergeMessage);
} else if (template) {
this.setCommitMessage(template);
}
}

getCommitMessage() {
return this.commitMessage;
}

getCommitMessageTemplate() {
return this.git().getCommitMessageTemplate();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's odd to me that getCommitMessageTemplate() is a git operation, but setCommitMessageTemplate() is an accessor for an instance variable. Can we clarify the names to be less misleading somehow?

Also: should the result of this git operation be cached?

Fake edit: on reflection, caching this would cause some weird behavior because we don't have a filesystem watcher on the commit message template itself, so we wouldn't know when to invalidate that cache entry.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hahaha yup Katrina and I went through that exact conversation re caching.

renamed the method to fetchCommitMessageTemplate. I'm not 100% sure that's better, but perhaps folks won't think it's the inverse operation of setCommitMessageTemplate?


getOperationStates() {
return this.operationStates;
}
Expand All @@ -77,7 +103,8 @@ export default class Present extends State {
this.didUpdate();
}

observeFilesystemChange(paths) {
invalidateCacheAfterFilesystemChange(events) {
const paths = events.map(e => e.special || e.path);
const keys = new Set();
for (let i = 0; i < paths.length; i++) {
const fullPath = paths[i];
Expand Down Expand Up @@ -145,6 +172,49 @@ export default class Present extends State {
}
}

isCommitMessageClean() {
if (this.commitMessage.trim() === '') {
return true;
} else if (this.commitMessageTemplate) {
return this.commitMessage === this.commitMessageTemplate;
}
return false;
}

async updateCommitMessageAfterFileSystemChange(events) {
for (let i = 0; i < events.length; i++) {
const event = events[i];
const endsWith = (...segments) => event.path.endsWith(path.join(...segments));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth extracting this to helpers.js?

It is a one-liner, but eh.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure! Good suggestion. Extracted.


if (endsWith('.git', 'MERGE_HEAD')) {
if (event.action === 'created') {
if (this.isCommitMessageClean()) { // is it really necessary to check if commit message is clean?
this.setCommitMessage(await this.repository.getMergeMessage());
// this.didUpdate();
annthurium marked this conversation as resolved.
Show resolved Hide resolved
}
} else if (event.action === 'deleted') {
this.setCommitMessage(this.commitMessageTemplate || '');
// this.didUpdate();
}
}

if (endsWith('.git', 'config')) {
// this won't catch changes made to the template file itself...
const template = await this.getCommitMessageTemplate();
if (this.commitMessageTemplate !== template) {
this.setCommitMessageTemplate(template);
this.setCommitMessage(template);
// this.didUpdate();
}
}
}
}

observeFilesystemChange(events) {
this.invalidateCacheAfterFilesystemChange(events);
this.updateCommitMessageAfterFileSystemChange(events);
}

refresh() {
annthurium marked this conversation as resolved.
Show resolved Hide resolved
this.cache.clear();
this.didUpdate();
Expand Down Expand Up @@ -281,7 +351,10 @@ export default class Present extends State {
Keys.filePatch.all,
Keys.index.all,
],
() => this.git().abortMerge(),
async () => {
await this.git().abortMerge();
this.setCommitMessage(this.commitMessageTemplate || '');
annthurium marked this conversation as resolved.
Show resolved Hide resolved
},
);
}

Expand Down
4 changes: 4 additions & 0 deletions lib/models/repository-states/state.js
Expand Up @@ -373,6 +373,10 @@ export default class State {
return '';
}

getCommitMessageTemplate() {
return unsupportedOperationPromise(this, 'getCommitMessageTemplate');
}

// Cache

getCache() {
Expand Down
1 change: 1 addition & 0 deletions lib/models/repository.js
Expand Up @@ -358,6 +358,7 @@ const delegates = [

'setCommitMessage',
'getCommitMessage',
'getCommitMessageTemplate',
'getCache',
];

Expand Down
3 changes: 1 addition & 2 deletions lib/models/workdir-context.js
Expand Up @@ -50,8 +50,7 @@ export default class WorkdirContext {
// Wire up event forwarding among models
this.subs.add(this.repository.onDidChangeState(this.repositoryChangedState));
this.subs.add(this.observer.onDidChange(events => {
const paths = events.map(e => e.special || e.path);
this.repository.observeFilesystemChange(paths);
this.repository.observeFilesystemChange(events);
}));
this.subs.add(this.observer.onDidChangeWorkdirOrHead(() => this.emitter.emit('did-change-workdir-or-head')));

Expand Down
12 changes: 10 additions & 2 deletions lib/views/commit-view.js
Expand Up @@ -468,13 +468,16 @@ export default class CommitView extends React.Component {
}
}

isValidMessage() {
return this.editor && this.editor.getText().replace(/^#.*$/gm, '').trim().length !== 0;
annthurium marked this conversation as resolved.
Show resolved Hide resolved
}

commitIsEnabled(amend) {
const messageExists = this.editor && this.editor.getText().length !== 0;
return !this.props.isCommitting &&
(amend || this.props.stagedChangesExist) &&
!this.props.mergeConflictsExist &&
this.props.lastCommit.isPresent() &&
(this.props.deactivateCommitBox || (amend || messageExists));
(this.props.deactivateCommitBox || (amend || this.isValidMessage()));
}

commitButtonText() {
Expand Down Expand Up @@ -588,6 +591,11 @@ export default class CommitView extends React.Component {

if (focus === CommitView.focus.EDITOR) {
if (this.refEditor.map(focusElement).getOr(false)) {
if (this.editor && this.editor.getText().length > 0 && !this.isValidMessage()) {
annthurium marked this conversation as resolved.
Show resolved Hide resolved
// there is likely a commit message template present
// we want the cursor to be at the beginning, not at the and of the template
this.editor.setCursorBufferPosition([0, 0]);
}
return true;
}
}
Expand Down
50 changes: 32 additions & 18 deletions test/controllers/commit-controller.test.js
Expand Up @@ -40,7 +40,6 @@ describe('CommitController', function() {
isMerging={false}
mergeConflictsExist={false}
stagedChangesExist={false}
mergeMessage={''}
lastCommit={lastCommit}
currentBranch={nullBranch}
userStore={store}
Expand All @@ -61,19 +60,29 @@ describe('CommitController', function() {
const workdirPath2 = await cloneRepository('three-files');
const repository2 = await buildRepository(workdirPath2);

// set commit template for repository2
const templatePath = path.join(workdirPath2, 'a.txt');
const templateText = fs.readFileSync(templatePath, 'utf8');
await repository2.git.setConfig('commit.template', templatePath);
await assert.async.strictEqual(repository2.getCommitMessage(), templateText);

app = React.cloneElement(app, {repository: repository1});
const wrapper = shallow(app, {disableLifecycleMethods: true});

assert.strictEqual(wrapper.instance().getCommitMessage(), '');

wrapper.instance().setCommitMessage('message 1');
assert.equal(wrapper.instance().getCommitMessage(), 'message 1');

wrapper.setProps({repository: repository2});

assert.strictEqual(wrapper.instance().getCommitMessage(), '');
await assert.async.strictEqual(wrapper.instance().getCommitMessage(), templateText);
wrapper.instance().setCommitMessage('message 2');

wrapper.setProps({repository: repository1});
assert.equal(wrapper.instance().getCommitMessage(), 'message 1');

wrapper.setProps({repository: repository2});
assert.equal(wrapper.instance().getCommitMessage(), 'message 2');
});

describe('the passed commit message', function() {
Expand All @@ -100,21 +109,6 @@ describe('CommitController', function() {
assert.strictEqual(wrapper.getCommitMessage(), 'new message');
assert.isFalse(wrapper.props.repository.state.didUpdate.called);
});

describe('when a merge message is defined', function() {
it('is set to the merge message when merging', function() {
app = React.cloneElement(app, {isMerging: true, mergeMessage: 'merge conflict!'});
const wrapper = shallow(app, {disableLifecycleMethods: true});
assert.strictEqual(wrapper.find('CommitView').prop('message'), 'merge conflict!');
});

it('is set to getCommitMessage() if it is set', function() {
repository.setCommitMessage('some commit message');
app = React.cloneElement(app, {isMerging: true, mergeMessage: 'merge conflict!'});
const wrapper = shallow(app, {disableLifecycleMethods: true});
assert.strictEqual(wrapper.find('CommitView').prop('message'), 'some commit message');
});
});
});

describe('committing', function() {
Expand Down Expand Up @@ -173,6 +167,26 @@ describe('CommitController', function() {
assert.strictEqual(repository.getCommitMessage(), 'some message');
});

it('restores template after committing', async function() {
const templatePath = path.join(workdirPath, 'a.txt');
const templateText = fs.readFileSync(templatePath, 'utf8');
await repository.git.setConfig('commit.template', templatePath);
await assert.async.strictEqual(repository.getCommitMessage(), templateText);

const nonTemplateText = 'some new text...';
repository.setCommitMessage(nonTemplateText);

assert.strictEqual(repository.getCommitMessage(), nonTemplateText);

app = React.cloneElement(app, {repository});
const wrapper = shallow(app, {disableLifecycleMethods: true});
await fs.writeFile(path.join(workdirPath, 'new-file.txt'), 'some changes', {encoding: 'utf8'});
await repository.stageFiles(['new-file.txt']);
await wrapper.instance().commit(nonTemplateText);

await assert.async.strictEqual(repository.getCommitMessage(), templateText);
});

describe('message formatting', function() {
let commitSpy, wrapper;

Expand Down