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 { resolve: resolvePath } = require('path');
|
16 | const { PassThrough } = require('stream');
|
17 |
|
18 | const fse = require('fs-extra');
|
19 | const git = require('isomorphic-git');
|
20 | git.plugins.set('fs', require('fs'));
|
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({ dir, fullname: false });
|
36 | }
|
37 |
|
38 | /**
|
39 | * Parses Github url path subsegment `<ref>/<filePath>` (e.g. `master/some/file.txt`
|
40 | * or `some/branch/some/file.txt`) and returns an `{ ref, fpath }` object.
|
41 | *
|
42 | * Issue #53: Handle branch names containing '/' (e.g. 'foo/bar')
|
43 | *
|
44 | * @param {string} dir git repo path
|
45 | * @param {string} refPathName path including reference (branch or tag) and file path
|
46 | * (e.g. `master/some/file.txt` or `some/branch/some/file.txt`)
|
47 | * @returns {Promise<object>} an `{ ref, pathName }` object or `undefined` if the ref cannot
|
48 | * be resolved to an existing branch or tag.
|
49 | */
|
50 | async function determineRefPathName(dir, refPathName) {
|
51 | const branches = await git.listBranches({ dir });
|
52 | const tags = await git.listTags({ dir });
|
53 | const refs = branches.concat(tags);
|
54 | // find matching refs
|
55 | const matchingRefs = refs.filter((ref) => refPathName.startsWith(`${ref}/`));
|
56 | if (!matchingRefs.length) {
|
57 | return undefined;
|
58 | }
|
59 | // find longest matching ref
|
60 | const matchingRef = matchingRefs.reduce((a, b) => ((b.length > a.length) ? b : a));
|
61 | return { ref: matchingRef, pathName: refPathName.substr(matchingRef.length) };
|
62 | }
|
63 |
|
64 | /**
|
65 | * Determines whether the specified reference is currently checked out in the working dir.
|
66 | *
|
67 | * @param {string} dir git repo path
|
68 | * @param {string} ref reference (branch or tag)
|
69 | * @returns {Promise<boolean>} `true` if the specified reference is checked out
|
70 | */
|
71 | async function isCheckedOut(dir, ref) {
|
72 | let oidCurrent;
|
73 | return git.resolveRef({ dir, ref: 'HEAD' })
|
74 | .then((oid) => {
|
75 | oidCurrent = oid;
|
76 | return git.resolveRef({ dir, ref });
|
77 | })
|
78 | .then((oid) => oidCurrent === oid)
|
79 | .catch(() => false);
|
80 | }
|
81 |
|
82 | /**
|
83 | * Returns the commit oid of the curent commit referenced by `ref`
|
84 | *
|
85 | * @param {string} dir git repo path
|
86 | * @param {string} ref reference (branch, tag or commit sha)
|
87 | * @returns {Promise<string>} commit oid of the curent commit referenced by `ref`
|
88 | * @throws {GitError} `err.code === 'ResolveRefError'`: invalid reference
|
89 | */
|
90 | async function resolveCommit(dir, ref) {
|
91 | return git.resolveRef({ dir, ref })
|
92 | .catch(async (err) => {
|
93 | if (err.code === 'ResolveRefError') {
|
94 | // fallback: is ref a shortened oid prefix?
|
95 | const oid = await git.expandOid({ dir, oid: ref }).catch(() => { throw err; });
|
96 | return git.resolveRef({ dir, ref: oid });
|
97 | }
|
98 | // re-throw
|
99 | throw err;
|
100 | });
|
101 | }
|
102 |
|
103 | /**
|
104 | * Returns the blob oid of the file at revision `ref` and `pathName`
|
105 | *
|
106 | * @param {string} dir git repo path
|
107 | * @param {string} ref reference (branch, tag or commit sha)
|
108 | * @param {string} filePath relative path to file
|
109 | * @param {boolean} includeUncommitted include uncommitted changes in working dir
|
110 | * @returns {Promise<string>} blob oid of specified file
|
111 | * @throws {GitError} `err.code === 'TreeOrBlobNotFoundError'`: resource not found
|
112 | * `err.code === 'ResolveRefError'`: invalid reference
|
113 | */
|
114 | async function resolveBlob(dir, ref, pathName, includeUncommitted) {
|
115 | const commitSha = await resolveCommit(dir, ref);
|
116 |
|
117 | // project-helix/#150: check for uncommitted local changes
|
118 | // project-helix/#183: serve newly created uncommitted files
|
119 | // project-helix/#187: only serve uncommitted content if currently
|
120 | // checked-out and requested refs match
|
121 |
|
122 | if (!includeUncommitted) {
|
123 | return (await git.readObject({ dir, oid: commitSha, filepath: pathName })).oid;
|
124 | }
|
125 | // check working dir status
|
126 | const status = await git.status({ dir, filepath: pathName });
|
127 | if (status.endsWith('unmodified')) {
|
128 | return (await git.readObject({ dir, oid: commitSha, filepath: pathName })).oid;
|
129 | }
|
130 | if (status.endsWith('absent') || status.endsWith('deleted')) {
|
131 | const err = new Error(`Not found: ${pathName}`);
|
132 | err.code = git.E.TreeOrBlobNotFoundError;
|
133 | throw err;
|
134 | }
|
135 | // temporary workaround for https://github.com/isomorphic-git/isomorphic-git/issues/752
|
136 | // => remove once isomorphic-git #252 is fixed
|
137 | if (status.endsWith('added') && !await pathExists(dir, pathName)) {
|
138 | const err = new Error(`Not found: ${pathName}`);
|
139 | err.code = git.E.TreeOrBlobNotFoundError;
|
140 | throw err;
|
141 | }
|
142 | // return blob id representing working dir file
|
143 | const content = await fse.readFile(resolvePath(dir, pathName));
|
144 | return git.writeObject({
|
145 | dir,
|
146 | object: content,
|
147 | type: 'blob',
|
148 | format: 'content',
|
149 | });
|
150 | }
|
151 |
|
152 | /**
|
153 | * Returns the contents of the file at revision `ref` and `pathName`
|
154 | *
|
155 | * @param {string} dir git repo path
|
156 | * @param {string} ref reference (branch, tag or commit sha)
|
157 | * @param {string} filePath relative path to file
|
158 | * @param {boolean} includeUncommitted include uncommitted changes in working dir
|
159 | * @returns {Promise<Buffer>} content of specified file
|
160 | * @throws {GitError} `err.code === 'TreeOrBlobNotFoundError'`: resource not found
|
161 | * `err.code === 'ResolveRefError'`: invalid reference
|
162 | */
|
163 | async function getRawContent(dir, ref, pathName, includeUncommitted) {
|
164 | return resolveBlob(dir, ref, pathName, includeUncommitted)
|
165 | .then((oid) => git.readObject({ dir, oid, format: 'content' }).object);
|
166 | }
|
167 |
|
168 | /**
|
169 | * Returns a stream for reading the specified blob.
|
170 | *
|
171 | * @param {string} dir git repo path
|
172 | * @param {string} oid blob sha1
|
173 | * @returns {Promise<Stream>} readable Stream instance
|
174 | */
|
175 | async function createBlobReadStream(dir, oid) {
|
176 | const { object: content } = await git.readObject({ dir, oid });
|
177 | const stream = new PassThrough();
|
178 | stream.end(content);
|
179 | return stream;
|
180 | }
|
181 |
|
182 | /**
|
183 | * Retrieves the specified object from the loose object store.
|
184 | *
|
185 | * @param {string} dir git repo path
|
186 | * @param {string} oid object id
|
187 | * @returns {Promise<Object>} object identified by `oid`
|
188 | */
|
189 | async function getObject(dir, oid) {
|
190 | return git.readObject({ dir, oid });
|
191 | }
|
192 |
|
193 | /**
|
194 | * Checks if the specified string is a valid SHA-1 value.
|
195 | *
|
196 | * @param {string} str
|
197 | * @returns {boolean} `true` if `str` represents a valid SHA-1, otherwise `false`
|
198 | */
|
199 | function isValidSha(str) {
|
200 | if (typeof str === 'string' && str.length === 40) {
|
201 | const res = str.match(/[0-9a-f]/g);
|
202 | return res && res.length === 40;
|
203 | }
|
204 | return false;
|
205 | }
|
206 |
|
207 | /**
|
208 | * Returns the tree object identified directly by its sha
|
209 | * or indirectly via reference (branch, tag or commit sha)
|
210 | *
|
211 | * @param {string} dir git repo path
|
212 | * @param {string} refOrSha either tree sha or reference (branch, tag or commit sha)
|
213 | * @returns {Promise<string>} commit oid of the curent commit referenced by `ref`
|
214 | * @throws {GitError} `err.code === 'ResolveRefError'`: invalid reference
|
215 | * `err.code === 'ReadObjectFail'`: not found
|
216 | */
|
217 | async function resolveTree(dir, refOrSha) {
|
218 | if (isValidSha(refOrSha)) {
|
219 | // full commit or tree sha
|
220 | return git.readObject({ dir, oid: refOrSha })
|
221 | .then((obj) => {
|
222 | if (obj.type === 'tree') {
|
223 | return obj;
|
224 | }
|
225 | if (obj.type === 'commit') {
|
226 | return git.readObject({ dir, oid: obj.object.tree });
|
227 | }
|
228 | throw new Error(`unexpected object: ${obj}`);
|
229 | });
|
230 | }
|
231 | // reference (branch, tag, shorthand commit sha)
|
232 | return resolveCommit(dir, refOrSha)
|
233 | .then((oid) => git.readObject({ dir, oid }))
|
234 | .then((obj) => git.readObject({ dir, oid: obj.object.tree }));
|
235 | }
|
236 |
|
237 | /**
|
238 | * Returns a commit log, i.e. an array of commits in reverse chronological order.
|
239 | *
|
240 | * @param {string} dir git repo path
|
241 | * @param {string} ref reference (branch, tag or commit sha)
|
242 | * @param {string} path only commits containing this file path will be returned
|
243 | * @throws {GitError} `err.code === 'ResolveRefError'`: invalid reference
|
244 | * `err.code === 'ReadObjectFail'`: not found
|
245 | */
|
246 | async function commitLog(dir, ref, path) {
|
247 | return git.log({ dir, ref, path })
|
248 | .catch(async (err) => {
|
249 | if (err.code === 'ResolveRefError') {
|
250 | // fallback: is ref a shortened oid prefix?
|
251 | const oid = await git.expandOid({ dir, oid: ref });
|
252 | return git.log({ dir, ref: oid, path });
|
253 | }
|
254 | // re-throw
|
255 | throw err;
|
256 | })
|
257 | .then(async (commits) => {
|
258 | if (typeof path === 'string' && path.length) {
|
259 | // filter by path
|
260 | let lastSHA = null;
|
261 | let lastCommit = null;
|
262 | const filteredCommits = [];
|
263 | for (let i = 0; i < commits.length; i += 1) {
|
264 | const commit = commits[i];
|
265 | /* eslint-disable no-await-in-loop */
|
266 | try {
|
267 | const o = await git.readObject({ dir, oid: commit.oid, filepath: path });
|
268 | if (i === commits.length - 1) {
|
269 | // file already existed in first commit
|
270 | filteredCommits.push(commit);
|
271 | break;
|
272 | }
|
273 | if (o.oid !== lastSHA) {
|
274 | if (lastSHA !== null) {
|
275 | filteredCommits.push(lastCommit);
|
276 | }
|
277 | lastSHA = o.oid;
|
278 | }
|
279 | } catch (err) {
|
280 | // file no longer there
|
281 | filteredCommits.push(lastCommit);
|
282 | break;
|
283 | }
|
284 | lastCommit = commit;
|
285 | }
|
286 | // unfiltered commits
|
287 | return filteredCommits;
|
288 | }
|
289 | // unfiltered commits
|
290 | return commits;
|
291 | });
|
292 | }
|
293 |
|
294 | module.exports = {
|
295 | currentBranch,
|
296 | getRawContent,
|
297 | resolveTree,
|
298 | resolveCommit,
|
299 | resolveBlob,
|
300 | isCheckedOut,
|
301 | createBlobReadStream,
|
302 | getObject,
|
303 | isValidSha,
|
304 | commitLog,
|
305 | determineRefPathName,
|
306 | };
|