Skip to content

Commit 86a55aa

Browse files
committed
bin: custom git command git node
1 parent 95cfe9f commit 86a55aa

File tree

8 files changed

+625
-3
lines changed

8 files changed

+625
-3
lines changed

bin/git-node

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
4+
const CMD = process.argv[2];
5+
const path = require('path');
6+
const { runAsync } = require('../lib/run');
7+
const fs = require('fs');
8+
9+
if (!CMD) {
10+
console.log('Run `git node help` to see how to use this');
11+
process.exit(1);
12+
}
13+
14+
const script = path.join(
15+
__dirname, '..', 'components', 'git', `git-node-${CMD}`);
16+
if (!fs.existsSync(script)) {
17+
console.error(`No such command: git node ${CMD}`);
18+
process.exit(1);
19+
}
20+
21+
runAsync(script, process.argv.slice(3));

components/git/git-node-help

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env node
2+
3+
console.log(`
4+
Usage:
5+
git node land --abort # abort a landing session
6+
git node land <PRID> # start a landing session
7+
git node land --apply # pull and apply patches
8+
git rebase -i upstream/master # edit every commit that's gonna stay
9+
git node land --amend # regenerate commit messages in HEAD
10+
git rebase --continue
11+
git node land --final # verify all the messages
12+
`);

components/git/git-node-land

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
#!/usr/bin/env node
2+
3+
const getMetadata = require('../metadata');
4+
const CLI = require('../../lib/cli');
5+
const cli = new CLI(process.stderr);
6+
const Request = require('../../lib/request');
7+
const req = new Request();
8+
const {
9+
runPromise, runAsync, runSync, forceRunAsync
10+
} = require('../../lib/run');
11+
const Session = require('../../lib/landing_session');
12+
const dir = process.cwd();
13+
const args = process.argv.slice(2);
14+
15+
const START = 'START';
16+
const APPLY = 'APPLY';
17+
const AMEND = 'AMEND';
18+
const FINAL = 'FINAL';
19+
const CONTINUE = 'CONTINUE';
20+
const ABORT = 'ABORT';
21+
22+
const states = [
23+
[START, (args) => !isNaN(parseInt(args[0]))],
24+
[CONTINUE, (args) => args[0] === '--continue'],
25+
[APPLY, (args) => args[0] === '--apply'],
26+
[AMEND, (args) => args[0] === '--amend'],
27+
[FINAL, (args) => args[0] === '--final'],
28+
[ABORT, (args) => args[0] === '--abort']
29+
];
30+
31+
const result = states.filter(([state, pred]) => pred(args));
32+
if (result.length) {
33+
runPromise(main(result[0][0], args).catch((err) => {
34+
if (cli.spinner.enabled) {
35+
cli.spinner.fail();
36+
}
37+
throw err;
38+
}));
39+
} else {
40+
cli.error('Usage: `git node land <PRID>`');
41+
process.exit(1);
42+
}
43+
44+
async function main(state, args) {
45+
let session;
46+
47+
try {
48+
session = Session.restore(dir);
49+
} catch (err) { // JSON error?
50+
if (state === ABORT) {
51+
session = new Session(dir);
52+
await abort(session);
53+
return;
54+
}
55+
cli.warn(
56+
'Failed to detect previous session. ' +
57+
'please run `git node land --abort`');
58+
return;
59+
}
60+
61+
if (state === START) {
62+
if (session.hasStarted()) {
63+
cli.warn(
64+
'Previous `git node land` session for ' +
65+
`${session.pullName} in progress.`);
66+
cli.log('run `git node land --abort` before starting a new session');
67+
return;
68+
}
69+
session = new Session(dir, parseInt(args[0]));
70+
await start(session);
71+
} else if (state === APPLY) {
72+
await apply(session);
73+
} else if (state === AMEND) {
74+
await amend(session);
75+
} else if (state === FINAL) {
76+
await final(session);
77+
} else if (state === ABORT) {
78+
await abort(session);
79+
} else if (state === CONTINUE) {
80+
await continueSession(session);
81+
}
82+
}
83+
84+
async function start(session) {
85+
session.start();
86+
const { repo, owner, prid } = session;
87+
const result = await getMetadata({ repo, owner, prid }, cli);
88+
89+
const status = result.status ? 'should be ready' : 'is not ready';
90+
const response = await cli.prompt(
91+
`This PR ${status} to land, do you want to continue?`);
92+
if (response) {
93+
session.saveMetadata(result);
94+
session.startApplying();
95+
return apply(session);
96+
} else {
97+
await abort(session);
98+
process.exit();
99+
}
100+
}
101+
102+
function getNotYetPushedCommits(session, verbose) {
103+
const upstream = session.upstream;
104+
const branch = session.branch;
105+
var revs;
106+
if (verbose) {
107+
revs = runSync('git',
108+
['log', '--oneline', `${upstream}/${branch}...HEAD`]);
109+
} else {
110+
revs = runSync('git', ['rev-list', `${upstream}/${branch}...HEAD`]);
111+
}
112+
113+
if (!revs.trim()) {
114+
return [];
115+
}
116+
return revs.trim().split('\n');
117+
}
118+
119+
async function tryAbortAm(session, cli) {
120+
if (session.amInProgress()) {
121+
const shouldAbortAm = await cli.prompt(
122+
'Abort previous git am sessions?');
123+
if (shouldAbortAm) {
124+
await forceRunAsync('git', ['am', '--abort']);
125+
cli.ok('Aborted previous git am sessions');
126+
}
127+
} else {
128+
cli.ok('No git am in progress');
129+
}
130+
}
131+
132+
async function tryAbortRebase(session, cli) {
133+
if (session.rebaseInProgress()) {
134+
const shouldAbortRebase = await cli.prompt(
135+
'Abort previous git rebase sessions?');
136+
if (shouldAbortRebase) {
137+
await forceRunAsync('git', ['rebase', '--abort']);
138+
cli.ok('Aborted previous git rebase sessions');
139+
}
140+
} else {
141+
cli.ok('No git rebase in progress');
142+
}
143+
}
144+
145+
async function tryResetHead(session, cli) {
146+
const branch = `${session.upstream}/${session.branch}`;
147+
cli.startSpinner(`Bringing ${branch} up to date`);
148+
await runAsync('git',
149+
['fetch', session.upstream, session.branch]);
150+
cli.stopSpinner(`${branch} is now up-to-date`);
151+
const notYetPushed = getNotYetPushedCommits(session, true);
152+
if (notYetPushed.length) {
153+
const branch = `${session.upstream}/${session.branch}`;
154+
cli.log(`Found strayed commits in ${branch}:\n` +
155+
` - ${notYetPushed.join('\n - ')}`);
156+
const shouldReset = await cli.prompt(`Reset to ${branch}?`);
157+
if (shouldReset) {
158+
await runAsync('git', ['reset', '--hard', branch]);
159+
cli.ok(`Reset to ${branch}`);
160+
}
161+
}
162+
}
163+
164+
async function tryResetBranch(session, cli) {
165+
await tryAbortAm(session, cli);
166+
await tryAbortRebase(session, cli);
167+
168+
const branch = `${session.upstream}/${session.branch}`;
169+
const shouldResetHead = await cli.prompt(
170+
`Do you want to try reset the branch to ${branch}?`);
171+
if (shouldResetHead) {
172+
await tryResetHead(session, cli);
173+
}
174+
}
175+
176+
async function abort(session) {
177+
session.abort();
178+
await tryResetBranch(session, cli);
179+
cli.log(`Aborted \`git node land\` session in ${session.ncuDir}`);
180+
}
181+
182+
async function apply(session) {
183+
if (!session.readyToApply()) {
184+
cli.warn('This session can not proceed to apply patches, ' +
185+
'run `git node land --abort`');
186+
return;
187+
}
188+
189+
await tryResetBranch(session, cli);
190+
191+
const { repo, owner, prid } = session;
192+
// TODO: restore previously downloaded patches
193+
cli.startSpinner(`Downloading patch for ${prid}`);
194+
const patch = await req.promise({
195+
url: `https://github.com/${owner}/${repo}/pull/${prid}.patch`
196+
});
197+
session.savePatch(patch);
198+
cli.stopSpinner(`Downloaded patch to ${session.patchPath}`);
199+
200+
// TODO: check that patches downloaded match metadata.commits
201+
await runAsync('git', ['am', '--whitespace=fix', session.patchPath]);
202+
cli.ok('Patches applied');
203+
204+
session.startAmending();
205+
if (/Subject: \[PATCH\]/.test(patch)) {
206+
const shouldAmend = await cli.prompt(
207+
'There is only one commit in this PR.\n' +
208+
'do you want to amend the commit message?');
209+
if (shouldAmend) {
210+
const canFinal = await amend(session);
211+
if (canFinal) {
212+
return final(session);
213+
}
214+
}
215+
} else {
216+
const re = /Subject: \[PATCH 1\/(\d+)\]/;
217+
const match = patch.match(re);
218+
if (!match) {
219+
cli.warn('Cannot get number of commits in the patch. ' +
220+
'It seems to be malformed');
221+
return;
222+
}
223+
const upstream = session.upstream;
224+
const branch = session.branch;
225+
cli.log(
226+
`There are ${match[1]} commits in the PR.\n` +
227+
`Please run \`git rebase ${upstream}/${branch} -i\` ` +
228+
'and use `git node land --amend` to amend the commit messages');
229+
// TODO: do git rebase automatically?
230+
}
231+
}
232+
233+
async function amend(session) {
234+
if (!session.readyToAmend()) {
235+
cli.warn('Not yet ready to amend, run `git node land --abort`');
236+
return;
237+
}
238+
239+
const rev = runSync('git', ['rev-parse', 'HEAD']);
240+
const original = runSync('git', ['show', 'HEAD', '-s', '--format=%B']).trim();
241+
const metadata = session.metadata.trim().split('\n');
242+
const amended = original.split('\n');
243+
if (amended[amended.length - 1] !== '') {
244+
amended.push('');
245+
}
246+
247+
for (const line of metadata) {
248+
if (original.includes(line)) {
249+
if (line) {
250+
cli.warn(`Found ${line}, skipping..`);
251+
}
252+
} else {
253+
amended.push(line);
254+
}
255+
}
256+
257+
const message = amended.join('\n') + '\n';
258+
const messageFile = session.saveMessage(rev, message);
259+
cli.separator('New Message');
260+
cli.log(message.trim());
261+
cli.separator();
262+
const takeMessage = await cli.prompt('Use this message?');
263+
if (takeMessage) {
264+
await runAsync('git', ['commit', '--amend', '-F', messageFile]);
265+
// session.markAsAmended(rev);
266+
return true;
267+
}
268+
269+
cli.log(`Please manually edit ${messageFile}, then run\n` +
270+
`\`git commit --amend -F ${messageFile}\` to finish amending the message`);
271+
return false;
272+
};
273+
274+
async function final(session) {
275+
if (!session.readyToFinal()) { // check git rebase/am has been done
276+
cli.warn('Not yet ready to final');
277+
return;
278+
}
279+
const upstream = session.upstream;
280+
const branch = session.branch;
281+
const notYetPushed = getNotYetPushedCommits(session);
282+
const notYetPushedVerbose = getNotYetPushedCommits(session, true);
283+
await runAsync('core-validate-commit', notYetPushed);
284+
cli.separator();
285+
cli.log('The following commits are ready to be pushed to ' +
286+
`${upstream}/${branch}`);
287+
cli.log(`- ${notYetPushedVerbose.join('\n- ')}`);
288+
cli.separator();
289+
cli.log(`run \`git push ${upstream} ${branch}\` to finish landing`);
290+
const shouldClean = await cli.prompt('Clean up generated temporary files?');
291+
if (shouldClean) {
292+
session.cleanFiles();
293+
}
294+
}
295+
296+
async function continueSession(session) {
297+
if (session.readyToFinal()) {
298+
return final(session);
299+
}
300+
if (session.readyToAmend()) {
301+
return amend(session);
302+
}
303+
if (session.readyToApply()) {
304+
return apply(session);
305+
}
306+
if (session.hasStarted()) {
307+
return apply(session);
308+
}
309+
cli.log(
310+
'Please run `git node land <PRID> to start a landing session`');
311+
}

lib/cli.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const ora = require('ora');
44
const { EOL } = require('os');
55
const chalk = require('chalk');
6+
const read = require('read');
67

78
const { warning, error, info, success } = require('./figures');
89

@@ -25,6 +26,28 @@ class CLI {
2526
this.SPINNER_STATUS = SPINNER_STATUS;
2627
}
2728

29+
prompt(question, defaultAnswer = true) {
30+
const option =
31+
`[${(defaultAnswer ? 'Y' : 'y')}/${(defaultAnswer ? 'n' : 'N')}]`;
32+
return new Promise((resolve, reject) => {
33+
read({prompt: `${question} ${option} `}, (err, answer) => {
34+
if (err) {
35+
reject(err);
36+
}
37+
if (answer === undefined || answer === null) {
38+
reject(new Error('__ignore__'));
39+
}
40+
const trimmed = answer.toLowerCase().trim();
41+
if (!trimmed) {
42+
resolve(defaultAnswer);
43+
} else if (trimmed === 'y') {
44+
resolve(true);
45+
}
46+
resolve(false);
47+
});
48+
});
49+
}
50+
2851
startSpinner(text) {
2952
this.spinner.text = text;
3053
this.spinner.start();

0 commit comments

Comments
 (0)