UNPKG

12.7 kBJavaScriptView Raw
1/*
2 * Copyright 2018 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13'use strict';
14
15const fs = require('fs');
16const { resolve: resolvePath, join: joinPaths } = require('path');
17const { PassThrough } = require('stream');
18
19const fse = require('fs-extra');
20const git = require('isomorphic-git');
21
22const { pathExists } = require('./utils');
23
24/**
25 * Various helper functions for reading git meta-data and content
26 */
27
28/**
29 * Returns the name (abbreviated form) of the currently checked out branch.
30 *
31 * @param {string} dir git repo path
32 * @returns {Promise<string>} name of the currently checked out branch
33 */
34async function currentBranch(dir) {
35 return git.currentBranch({ fs, dir, fullname: false });
36}
37
38/**
39 * Returns the name (abbreviated form) of the default branch.
40 *
41 * The 'default branch' is a GitHub concept and doesn't exist
42 * for local git repositories. This method uses a simple heuristic to
43 * determnine the 'default branch' of a local git repository.
44 *
45 * @param {string} dir git repo path
46 * @returns {Promise<string>} name of the default branch
47 */
48async function defaultBranch(dir) {
49 const branches = await git.listBranches({ fs, dir });
50 if (branches.includes('main')) {
51 return 'main';
52 }
53 if (branches.includes('master')) {
54 return 'master';
55 }
56 return currentBranch(dir);
57}
58
59/**
60 * Parses Github url path subsegment `<ref>/<filePath>` (e.g. `main/some/file.txt`
61 * or `some/branch/some/file.txt`) and returns an `{ ref, fpath }` object.
62 *
63 * Issue #53: Handle branch names containing '/' (e.g. 'foo/bar')
64 *
65 * @param {string} dir git repo path
66 * @param {string} refPathName path including reference (branch or tag) and file path
67 * (e.g. `main/some/file.txt` or `some/branch/some/file.txt`)
68 * @returns {Promise<object>} an `{ ref, pathName }` object or `undefined` if the ref cannot
69 * be resolved to an existing branch or tag.
70 */
71async function determineRefPathName(dir, refPathName) {
72 const branches = await git.listBranches({ fs, dir });
73 const tags = await git.listTags({ fs, dir });
74 const refs = branches.concat(tags);
75 // find matching refs
76 const matchingRefs = refs.filter((ref) => refPathName.startsWith(`${ref}/`));
77 if (!matchingRefs.length) {
78 return undefined;
79 }
80 // find longest matching ref
81 const matchingRef = matchingRefs.reduce((a, b) => ((b.length > a.length) ? b : a));
82 return { ref: matchingRef, pathName: refPathName.substr(matchingRef.length) };
83}
84
85/**
86 * Determines whether the specified reference is currently checked out in the working dir.
87 *
88 * @param {string} dir git repo path
89 * @param {string} ref reference (branch or tag)
90 * @returns {Promise<boolean>} `true` if the specified reference is checked out
91 */
92async function isCheckedOut(dir, ref) {
93 let oidCurrent;
94 return git.resolveRef({ fs, dir, ref: 'HEAD' })
95 .then((oid) => {
96 oidCurrent = oid;
97 return git.resolveRef({ fs, dir, ref });
98 })
99 .then((oid) => oidCurrent === oid)
100 .catch(() => false);
101}
102
103/**
104 * Returns the commit oid of the curent commit referenced by `ref`
105 *
106 * @param {string} dir git repo path
107 * @param {string} ref reference (branch, tag or commit sha)
108 * @returns {Promise<string>} commit oid of the curent commit referenced by `ref`
109 * @throws {NotFoundError}: invalid reference
110 */
111async function resolveCommit(dir, ref) {
112 return git.resolveRef({ fs, dir, ref })
113 .catch(async (err) => {
114 if (err instanceof git.Errors.NotFoundError) {
115 // fallback: is ref a shortened oid prefix?
116 const oid = await git.expandOid({ fs, dir, oid: ref }).catch(() => { throw err; });
117 return git.resolveRef({ fs, dir, ref: oid });
118 }
119 // re-throw
120 throw err;
121 });
122}
123
124/**
125 * Returns the blob oid of the file at revision `ref` and `pathName`
126 *
127 * @param {string} dir git repo path
128 * @param {string} ref reference (branch, tag or commit sha)
129 * @param {string} filePath relative path to file
130 * @param {boolean} includeUncommitted include uncommitted changes in working dir
131 * @returns {Promise<string>} blob oid of specified file
132 * @throws {NotFoundError}: resource not found or invalid reference
133 */
134async function resolveBlob(dir, ref, pathName, includeUncommitted) {
135 const commitSha = await resolveCommit(dir, ref);
136
137 // project-helix/#150: check for uncommitted local changes
138 // project-helix/#183: serve newly created uncommitted files
139 // project-helix/#187: only serve uncommitted content if currently
140 // checked-out and requested refs match
141
142 if (!includeUncommitted) {
143 return (await git.readObject({
144 fs, dir, oid: commitSha, filepath: pathName,
145 })).oid;
146 }
147 // check working dir status
148 const status = await git.status({ fs, dir, filepath: pathName });
149 if (status.endsWith('unmodified')) {
150 return (await git.readObject({
151 fs, dir, oid: commitSha, filepath: pathName,
152 })).oid;
153 }
154 if (status.endsWith('absent') || status.endsWith('deleted')) {
155 throw new git.Errors.NotFoundError(pathName);
156 }
157 // temporary workaround for https://github.com/isomorphic-git/isomorphic-git/issues/752
158 // => remove once isomorphic-git #252 is fixed
159 if (status.endsWith('added') && !await pathExists(dir, pathName)) {
160 throw new git.Errors.NotFoundError(pathName);
161 }
162 try {
163 // return blob id representing working dir file
164 const content = await fse.readFile(resolvePath(dir, pathName));
165 return git.writeBlob({
166 fs,
167 dir,
168 blob: content,
169 });
170 } catch (e) {
171 // should all errors cause a NotFound ?
172 if (e.code === 'ENOENT' && status === 'ignored') {
173 throw new git.Errors.NotFoundError(pathName);
174 }
175 throw e;
176 }
177}
178
179/**
180 * Returns the contents of the file at revision `ref` and `pathName`
181 *
182 * @param {string} dir git repo path
183 * @param {string} ref reference (branch, tag or commit sha)
184 * @param {string} filePath relative path to file
185 * @param {boolean} includeUncommitted include uncommitted changes in working dir
186 * @returns {Promise<Buffer>} content of specified file
187 * @throws {NotFoundError}: resource not found or invalid reference
188 */
189async function getRawContent(dir, ref, pathName, includeUncommitted) {
190 return resolveBlob(dir, ref, pathName, includeUncommitted)
191 .then(async (oid) => (await git.readObject({
192 fs, dir, oid, format: 'content',
193 })).object);
194}
195
196/**
197 * Returns a stream for reading the specified blob.
198 *
199 * @param {string} dir git repo path
200 * @param {string} oid blob sha1
201 * @returns {Promise<Stream>} readable Stream instance
202 */
203async function createBlobReadStream(dir, oid) {
204 const { object: content } = await git.readObject({ fs, dir, oid });
205 const stream = new PassThrough();
206 stream.end(content);
207 return stream;
208}
209
210/**
211 * Retrieves the specified object from the loose object store.
212 *
213 * @param {string} dir git repo path
214 * @param {string} oid object id
215 * @returns {Promise<Object>} object identified by `oid`
216 */
217async function getObject(dir, oid) {
218 return git.readObject({ fs, dir, oid });
219}
220
221/**
222 * Checks if the specified string is a valid SHA-1 value.
223 *
224 * @param {string} str
225 * @returns {boolean} `true` if `str` represents a valid SHA-1, otherwise `false`
226 */
227function isValidSha(str) {
228 if (typeof str === 'string' && str.length === 40) {
229 const res = str.match(/[0-9a-f]/g);
230 return res && res.length === 40;
231 }
232 return false;
233}
234
235/**
236 * Returns the tree object identified directly by its sha
237 * or indirectly via reference (branch, tag or commit sha)
238 *
239 * @param {string} dir git repo path
240 * @param {string} refOrSha either tree sha or reference (branch, tag or commit sha)
241 * @returns {Promise<string>} commit oid of the curent commit referenced by `ref`
242 * @throws {NotFoundError}: not found or invalid reference
243 */
244async function resolveTree(dir, refOrSha) {
245 let oid;
246 if (isValidSha(refOrSha)) {
247 oid = refOrSha;
248 } else {
249 // not a full sha: ref or shortened oid prefix?
250 try {
251 oid = await git.resolveRef({ fs, dir, ref: refOrSha });
252 } catch (err) {
253 if (err instanceof git.Errors.NotFoundError) {
254 // fallback: is ref a shortened oid prefix?
255 oid = await git.expandOid({ fs, dir, oid: refOrSha }).catch(() => { throw err; });
256 } else {
257 // re-throw
258 throw err;
259 }
260 }
261 }
262
263 // resolved oid
264 return git.readObject({ fs, dir, oid })
265 .then((obj) => {
266 if (obj.type === 'tree') {
267 return obj;
268 }
269 if (obj.type === 'commit') {
270 return git.readObject({ fs, dir, oid: obj.object.tree });
271 }
272 throw new git.Errors.ObjectTypeError(oid, 'tree|commit', obj.type);
273 });
274}
275
276/**
277 * Returns a commit log, i.e. an array of commits in reverse chronological order.
278 *
279 * @param {string} dir git repo path
280 * @param {string} ref reference (branch, tag or commit sha)
281 * @param {string} path only commits containing this file path will be returned
282 * @throws {NotFoundError}: not found or invalid reference
283 */
284async function commitLog(dir, ref, path) {
285 return git.log({
286 fs, dir, ref, path,
287 })
288 .catch(async (err) => {
289 if (err instanceof git.Errors.NotFoundError) {
290 // fallback: is ref a shortened oid prefix?
291 const oid = await git.expandOid({ fs, dir, oid: ref }).catch(() => { throw err; });
292 return git.log({
293 fs, dir, ref: oid, path,
294 });
295 }
296 // re-throw
297 throw err;
298 })
299 .then(async (commits) => {
300 if (typeof path === 'string' && path.length) {
301 // filter by path
302 let lastSHA = null;
303 let lastCommit = null;
304 const filteredCommits = [];
305 for (let i = 0; i < commits.length; i += 1) {
306 const c = commits[i];
307 /* eslint-disable no-await-in-loop */
308 try {
309 const o = await git.readObject({
310 fs, dir, oid: c.oid, filepath: path,
311 });
312 if (i === commits.length - 1) {
313 // file already existed in first commit
314 filteredCommits.push(c);
315 break;
316 }
317 if (o.oid !== lastSHA) {
318 if (lastSHA !== null) {
319 filteredCommits.push(lastCommit);
320 }
321 lastSHA = o.oid;
322 }
323 } catch (err) {
324 if (lastCommit) {
325 // file no longer there
326 filteredCommits.push(lastCommit);
327 }
328 break;
329 }
330 lastCommit = c;
331 }
332 // filtered commits
333 return filteredCommits.map((c) => ({ oid: c.oid, ...c.commit }));
334 }
335 // unfiltered commits
336 return commits.map((c) => ({ oid: c.oid, ...c.commit }));
337 });
338}
339
340/**
341 * Recursively collects all tree entries (blobs and trees).
342 *
343 * @param {string} repPath git repository path
344 * @param {Array<object>} entries git tree entries to process
345 * @param {Array<object>} result array where tree entries will be collected
346 * @param {string} treePath path of specified tree (will be prepended to child entries)
347 * @param {boolean} deep recurse into subtrees?
348 * @returns {Promise<Array<object>>} collected entries
349 */
350async function collectTreeEntries(repPath, entries, result, treePath, deep = true) {
351 const items = await Promise.all(entries.map(async ({
352 oid, type, mode, path,
353 }) => ({
354 oid, type, mode, path: joinPaths(treePath, path),
355 })));
356 result.push(...items);
357 if (deep) {
358 // recurse into subtrees
359 const treeItems = items.filter((item) => item.type === 'tree');
360 for (let i = 0; i < treeItems.length; i += 1) {
361 const { oid, path } = treeItems[i];
362 /* eslint-disable no-await-in-loop */
363 const { object: subTreeEntries } = await getObject(repPath, oid);
364 await collectTreeEntries(repPath, subTreeEntries, result, path, deep);
365 }
366 }
367 return result;
368}
369
370module.exports = {
371 currentBranch,
372 defaultBranch,
373 getRawContent,
374 resolveTree,
375 resolveCommit,
376 resolveBlob,
377 isCheckedOut,
378 createBlobReadStream,
379 getObject,
380 isValidSha,
381 commitLog,
382 determineRefPathName,
383 collectTreeEntries,
384 NotFoundError: git.Errors.NotFoundError,
385 ObjectTypeError: git.Errors.ObjectTypeError,
386};