UNPKG

8.46 kBJavaScriptView Raw
1import { execSync } from 'child_process';
2import setupDebug from 'debug';
3import gql from 'fake-tag';
4import dedent from 'ts-dedent';
5
6import log from '../lib/log';
7
8const debug = setupDebug('chromatic-cli:git');
9
10async 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
49export const FETCH_N_INITIAL_BUILD_COMMITS = 20;
50
51const 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
65const TesterHasBuildsWithCommitsQuery = gql`
66 query TesterHasBuildsWithCommitsQuery($commits: [String!]!) {
67 app {
68 hasBuildsWithCommits(commits: $commits)
69 }
70 }
71`;
72
73// NOTE: At some point we should check that the commit has been pushed to the
74// remote and the branch matches with origin/REF, but for now we are naive about
75// adhoc builds.
76
77// We could cache this, but it's probably pretty quick
78export 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
86export async function getBranch() {
87 return execGitCommand(`git rev-parse --abbrev-ref HEAD`);
88}
89
90// Check if a commit exists in the repository
91async 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
100function commitsForCLI(commits) {
101 return commits.map(c => c.trim()).join(' ');
102}
103
104// git rev-list in a basic form gives us a list of commits reaching back to
105// `firstCommittedAtSeconds` (i.e. when the first build of this app happened)
106// in reverse chronological order.
107//
108// A simplified version of what we are doing here is just finding the first
109// commit in that list that has a build. We only want to send `limit` to
110// the server in this pass (although we may already know some commits that do
111// or do not have builds from earlier passes). So we just pick the first `limit`
112// commits from the command, filtering out `commitsWith[out]Builds`.
113//
114// However, it's not quite that simple -- because of branching. However,
115// passing commits after `--not` in to `git rev-list` *occludes* all the ancestors
116// of those commits. This is exactly what we need once we find one or more commits
117// that do have builds: a list of the ancestors of HEAD that are not accestors of
118// `commitsWithBuilds`.
119//
120async function nextCommits(
121 limit,
122 { firstCommittedAtSeconds, commitsWithBuilds, commitsWithoutBuilds }
123) {
124 // We want the next limit commits that aren't "covered" by `commitsWithBuilds`
125 // This will print out all commits in `commitsWithoutBuilds` (except if they are covered),
126 // so we ask enough that we'll definitely get `limit` unknown commits
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 // No sense in checking commits we already know about
137 .filter(c => !commitsWithBuilds.includes(c))
138 .filter(c => !commitsWithoutBuilds.includes(c))
139 .slice(0, limit)
140 );
141}
142
143// Which of the listed commits are "maximally descendent":
144// ie c in commits such that there are no descendents of c in commits.
145async function maximallyDescendentCommits(commits) {
146 if (commits.length === 0) {
147 return commits;
148 }
149
150 // <commit>^@ expands to all parents of commit
151 const parentCommits = commits.map(c => `"${c}^@"`);
152 // List the tree from <commits> not including the tree from <parentCommits>
153 // This just filters any commits that are ancestors of other commits
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// Exponentially iterate `limit` up to infinity to find a "covering" set of commits with builds
163async 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 // No more commits uncovered commitsWithBuilds!
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
204export async function getBaselineCommits(client, { branch, ignoreLastBuildOnBranch = false } = {}) {
205 const { committedAt } = await getCommit();
206
207 // Include the latest build from this branch as an ancestor of the current build
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 // Don't do any special branching logic for builds on `HEAD`, this is fairly meaningless
224 // (CI systems that have been pushed tags can not set a branch)
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 // Get a "covering" set of commits that have builds. This is a set of commits
240 // such that any ancestor of HEAD is either:
241 // - in commitsWithBuilds
242 // - an ancestor of a commit in commitsWithBuilds
243 // - has no build
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 // For any pair A,B of builds, there is no point in using B if it is an ancestor of A.
253 return [...extraBaselineCommits, ...(await maximallyDescendentCommits(commitsWithBuilds))];
254}