1 | import { execSync } from 'child_process';
|
2 | import setupDebug from 'debug';
|
3 | import gql from 'fake-tag';
|
4 | import dedent from 'ts-dedent';
|
5 |
|
6 | import log from '../lib/log';
|
7 |
|
8 | const debug = setupDebug('chromatic-cli:git');
|
9 |
|
10 | async function execGitCommand(command) {
|
11 | try {
|
12 | return execSync(`${command} 2>&1`)
|
13 | .toString()
|
14 | .trim();
|
15 | } catch (error) {
|
16 | const { output } = error;
|
17 |
|
18 | const message = output.toString();
|
19 |
|
20 | if (message.includes('not a git repository')) {
|
21 | throw new Error(dedent`
|
22 | Unable to execute git command '${command}'.
|
23 |
|
24 | Chromatic only works in git projects.
|
25 | Contact us at support@hichroma.com if you need to use Chromatic outside of one.
|
26 | `);
|
27 | }
|
28 |
|
29 | if (message.includes('git not found')) {
|
30 | throw new Error(dedent`
|
31 | Unable to execute git command '${command}'.
|
32 |
|
33 | Chromatic only works in with git installed.
|
34 | `);
|
35 | }
|
36 |
|
37 | if (message.includes('does not have any commits yet')) {
|
38 | throw new Error(dedent`
|
39 | Unable to execute git command '${command}'.
|
40 |
|
41 | Chromatic requires that you have created a commit before it can be run.
|
42 | `);
|
43 | }
|
44 |
|
45 | throw error;
|
46 | }
|
47 | }
|
48 |
|
49 | export const FETCH_N_INITIAL_BUILD_COMMITS = 20;
|
50 |
|
51 | const TesterFirstCommittedAtQuery = gql`
|
52 | query TesterFirstCommittedAtQuery($branch: String!) {
|
53 | app {
|
54 | firstBuild(sortByCommittedAt: true) {
|
55 | committedAt
|
56 | }
|
57 | lastBuild(branch: $branch, sortByCommittedAt: true) {
|
58 | commit
|
59 | committedAt
|
60 | }
|
61 | }
|
62 | }
|
63 | `;
|
64 |
|
65 | const TesterHasBuildsWithCommitsQuery = gql`
|
66 | query TesterHasBuildsWithCommitsQuery($commits: [String!]!) {
|
67 | app {
|
68 | hasBuildsWithCommits(commits: $commits)
|
69 | }
|
70 | }
|
71 | `;
|
72 |
|
73 |
|
74 |
|
75 |
|
76 |
|
77 |
|
78 | export async function getCommit() {
|
79 | const [commit, committedAtSeconds, committerEmail, committerName] = (
|
80 | await execGitCommand(`git log -n 1 --format="%H,%ct,%ce,%cn"`)
|
81 | ).split(',');
|
82 |
|
83 | return { commit, committedAt: committedAtSeconds * 1000, committerEmail, committerName };
|
84 | }
|
85 |
|
86 | export async function getBranch() {
|
87 | return execGitCommand(`git rev-parse --abbrev-ref HEAD`);
|
88 | }
|
89 |
|
90 |
|
91 | async function commitExists(commit) {
|
92 | try {
|
93 | await execGitCommand(`git cat-file -e "${commit}^{commit}"`);
|
94 | return true;
|
95 | } catch (error) {
|
96 | return false;
|
97 | }
|
98 | }
|
99 |
|
100 | function commitsForCLI(commits) {
|
101 | return commits.map(c => c.trim()).join(' ');
|
102 | }
|
103 |
|
104 |
|
105 |
|
106 |
|
107 |
|
108 |
|
109 |
|
110 |
|
111 |
|
112 |
|
113 |
|
114 |
|
115 |
|
116 |
|
117 |
|
118 |
|
119 |
|
120 | async function nextCommits(
|
121 | limit,
|
122 | { firstCommittedAtSeconds, commitsWithBuilds, commitsWithoutBuilds }
|
123 | ) {
|
124 |
|
125 |
|
126 |
|
127 | const command = `git rev-list HEAD \
|
128 | ${firstCommittedAtSeconds ? `--since ${firstCommittedAtSeconds}` : ''} \
|
129 | -n ${limit + commitsWithoutBuilds.length} --not ${commitsForCLI(commitsWithBuilds)}`;
|
130 | debug(`running ${command}`);
|
131 | const commits = (await execGitCommand(command)).split('\n').filter(c => !!c);
|
132 | debug(`command output: ${commits}`);
|
133 |
|
134 | return (
|
135 | commits
|
136 |
|
137 | .filter(c => !commitsWithBuilds.includes(c))
|
138 | .filter(c => !commitsWithoutBuilds.includes(c))
|
139 | .slice(0, limit)
|
140 | );
|
141 | }
|
142 |
|
143 |
|
144 |
|
145 | async function maximallyDescendentCommits(commits) {
|
146 | if (commits.length === 0) {
|
147 | return commits;
|
148 | }
|
149 |
|
150 |
|
151 | const parentCommits = commits.map(c => `"${c}^@"`);
|
152 |
|
153 |
|
154 | const command = `git rev-list ${commitsForCLI(commits)} --not ${commitsForCLI(parentCommits)}`;
|
155 | debug(`running ${command}`);
|
156 | const maxCommits = (await execGitCommand(command)).split('\n').filter(c => !!c);
|
157 | debug(`command output: ${maxCommits}`);
|
158 |
|
159 | return maxCommits;
|
160 | }
|
161 |
|
162 |
|
163 | async function step(
|
164 | client,
|
165 | limit,
|
166 | { firstCommittedAtSeconds, commitsWithBuilds, commitsWithoutBuilds }
|
167 | ) {
|
168 | debug(`step: checking ${limit} up to ${firstCommittedAtSeconds}`);
|
169 | debug(`step: commitsWithBuilds: ${commitsWithBuilds}`);
|
170 | debug(`step: commitsWithoutBuilds: ${commitsWithoutBuilds}`);
|
171 |
|
172 | const candidateCommits = await nextCommits(limit, {
|
173 | firstCommittedAtSeconds,
|
174 | commitsWithBuilds,
|
175 | commitsWithoutBuilds,
|
176 | });
|
177 |
|
178 | debug(`step: candidateCommits: ${candidateCommits}`);
|
179 |
|
180 |
|
181 | if (candidateCommits.length === 0) {
|
182 | debug('step: no candidateCommits; we are done');
|
183 | return commitsWithBuilds;
|
184 | }
|
185 |
|
186 | const {
|
187 | app: { hasBuildsWithCommits: newCommitsWithBuilds },
|
188 | } = await client.runQuery(TesterHasBuildsWithCommitsQuery, {
|
189 | commits: candidateCommits,
|
190 | });
|
191 | debug(`step: newCommitsWithBuilds: ${newCommitsWithBuilds}`);
|
192 |
|
193 | const newCommitsWithoutBuilds = candidateCommits.filter(
|
194 | commit => !newCommitsWithBuilds.find(c => c === commit)
|
195 | );
|
196 |
|
197 | return step(client, limit * 2, {
|
198 | firstCommittedAtSeconds,
|
199 | commitsWithBuilds: [...commitsWithBuilds, ...newCommitsWithBuilds],
|
200 | commitsWithoutBuilds: [...commitsWithoutBuilds, ...newCommitsWithoutBuilds],
|
201 | });
|
202 | }
|
203 |
|
204 | export async function getBaselineCommits(client, { branch, ignoreLastBuildOnBranch = false } = {}) {
|
205 | const { committedAt } = await getCommit();
|
206 |
|
207 |
|
208 | const {
|
209 | app: { firstBuild, lastBuild },
|
210 | } = await client.runQuery(TesterFirstCommittedAtQuery, {
|
211 | branch,
|
212 | });
|
213 | debug(`App firstBuild: ${firstBuild}, lastBuild: ${lastBuild}`);
|
214 |
|
215 | if (!firstBuild) {
|
216 | debug('App has no builds, returning []');
|
217 | return [];
|
218 | }
|
219 |
|
220 | const initialCommitsWithBuilds = [];
|
221 | const extraBaselineCommits = [];
|
222 |
|
223 |
|
224 |
|
225 | if (
|
226 | branch !== 'HEAD' &&
|
227 | !ignoreLastBuildOnBranch &&
|
228 | lastBuild &&
|
229 | lastBuild.committedAt <= committedAt
|
230 | ) {
|
231 | if (await commitExists(lastBuild.commit)) {
|
232 | initialCommitsWithBuilds.push(lastBuild.commit);
|
233 | } else {
|
234 | debug(`Last build commit not in index, blindly appending to baselines`);
|
235 | extraBaselineCommits.push(lastBuild.commit);
|
236 | }
|
237 | }
|
238 |
|
239 |
|
240 |
|
241 |
|
242 |
|
243 |
|
244 | const commitsWithBuilds = await step(client, FETCH_N_INITIAL_BUILD_COMMITS, {
|
245 | firstCommittedAtSeconds: firstBuild.committedAt && firstBuild.committedAt / 1000,
|
246 | commitsWithBuilds: initialCommitsWithBuilds,
|
247 | commitsWithoutBuilds: [],
|
248 | });
|
249 |
|
250 | debug(`Final commitsWithBuilds: ${commitsWithBuilds}`);
|
251 |
|
252 |
|
253 | return [...extraBaselineCommits, ...(await maximallyDescendentCommits(commitsWithBuilds))];
|
254 | }
|