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 | ;
|
14 |
|
15 | const fs = require('fs');
|
16 | const { resolve: resolvePath, join: joinPaths } = require('path');
|
17 | const { PassThrough } = require('stream');
|
18 |
|
19 | const fse = require('fs-extra');
|
20 | const git = require('isomorphic-git');
|
21 |
|
22 | const { 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 | */
|
34 | async 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 | */
|
48 | async 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 | */
|
71 | async 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 | */
|
92 | async 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 | */
|
111 | async 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 | */
|
134 | async 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 | */
|
189 | async 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 | */
|
203 | async 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 | */
|
217 | async 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 | */
|
227 | function 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 | */
|
244 | async 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 | */
|
284 | async 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 | */
|
350 | async 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 |
|
370 | module.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 | };
|