背景
近日写了一个chrome插件的starter-boilerplate。但这类boilerplate被人们使用的方式常常是整合在cli库中。由于仓库本身的模板性质和git历史存在,并不合适使用npm分发或git clone
快速搭建项目骨架。
碰巧此前学习svelte的时候接触到了degit,degit做的事很简单,复制git仓库代码。这也正是一个称职的boilerplate发挥光和热的方式。
degit使用
1 2 3 4 5 6 7 8
| degit user/repo
degit github:user/repo degit git@github.com:user/repo degit https://github.com/user/repo
degit user/repo my-new-project
|
上面是一个degit的基本用法,类似git clone
指定仓库地址和本地目录名,默认将项目当前master
分支的代码拷贝到本地。还可以在仓库后使用#
分隔,指定分支名、tag名或commit hash。目前(2019/11/12)degit支持github、gitlab、BitBucket以及Sourcehut,暂不支持私有仓库。
在一些情况下,我们可能希望在拷贝完代码后进行一些后置操作,如拷贝关联仓库或删除不必要文件等。对此,degit设计了actions来支持,可以在当前目录的degit.json
中声明。目前actions只有clone
和remove
两种。
1 2 3 4 5 6 7 8 9 10 11
| [ { "action": "clone", "src": "user/another-repo" }, { "action": "remove", "files": ["LICENSE"] } ]
|
degit优势
如README中提到的,degit和git clone --depth 1
还是有所区别的:
git clone
后,终归还是会有个.git
目录,需要手动重置
- degit在实现时增加了缓存策略,在有些情况下不需要重复下载代码,速度更快
- “更少的字数”(
degit user/repo
而不是git clone --depth 1 git@github.com:user/repo
)
- 灵活度更高,如前后置操作如actions的支持
- 更好的可扩展性,未来可以在degit基础上实现交互等更复杂的设计
degit原理
那么degit快在哪里?它的思路借鉴于zel和gittar,即方便快捷地从git仓库中下载需要的源代码。原理上,利用某些git平台url的特定规则,从平台下载tar.gz包,再本地解压。
degit实现集中在src/index.js
中。src/bin.js
只用来实现cli部分的入口代码,src/utils.js
则包含了一些工具函数。
入口
在src/bin.js
中,流程分下面几步:
- 利用mri做了基本的参数处理
- 实例化Degit对象,注册logger的监听方法
- 调用
clone
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const d = degit(src, args);
d.on('info', event => { console.error(chalk.cyan(`> ${event.message.replace('options.', '--')}`)); });
d.on('warn', event => { console.error( chalk.magenta(`! ${event.message.replace('options.', '--')}`) ); });
d.clone(dest)
.catch(err => { console.error(chalk.red(`! ${err.message.replace('options.', '--')}`)); process.exit(1); });
|
Degit初始化
对象实例包含下面几个成员,其中repo信息需要处理后才能拿到。
src
,string,用户输入的仓库地址
cache
,boolean,是否使用缓存,来自命令行-c
或--cache
参数
force
,boolean,目标文件夹有内容时,是否覆盖,来自-f
或--force
参数
verbose
,boolean,是否打印详细日志,来自-v
或--verbose
参数
repo
,处理src
拿到仓库的详情,包括
site
,网页域名
user
,用户/组织名
name
,仓库名
ref
,分支、tag、commit hash
url
,完整的HTTP url
directiveActions
,actions配置对应的处理函数,包含
clone
,递归处理src的仓库
remove
,调用remove
方法移除指定文件
repo信息来自src经过正则匹配出的详细信息。由于要利用一些git平台的url拼接规则,需要排除已知平台以外的url。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| const supported = new Set(['github', 'gitlab', 'bitbucket', 'git.sr.ht']); function parse(src) { const match = /^(?:https:\/\/([^/]+)\/|git@([^/]+)[:/]|([^/]+)[:/])?([^/\s]+)\/([^/\s#]+)(?:#(.+))?/.exec( src ); if (!match) { throw new DegitError(`could not parse ${src}`, { code: 'BAD_SRC', }); }
const site = (match[1] || match[2] || match[3] || 'github').replace( /\.(com|org)$/, '' ); if (!supported.has(site)) { throw new DegitError( `degit supports GitHub, GitLab, Sourcehut and BitBucket`, { code: 'UNSUPPORTED_HOST', } ); }
const user = match[4]; const name = match[5].replace(/\.git$/, ''); const ref = match[6] || 'master';
const url = `https://${site}.${ site === 'bitbucket' ? 'org' : site === 'git.sr.ht' ? '' : 'com' }/${user}/${name}`;
return { site, user, name, ref, url }; }
|
仓库下载
下载仓库流程如下:
获取缓存信息
degit的缓存放在/home
或/tmp
下的.degit
目录下,按照site/user/name
的目录组织。
1 2 3 4 5
| const base = path.join(homeOrTmp, '.degit');
const dir = path.join(base, repo.site, repo.user, repo.name); const cached = tryRequire(path.join(dir, 'map.json')) || {};
|
目录下有一个map.json
和缓存的代码tar.gz包,包名格式为<commit-hash>.tar.gz
。在map.json保存着此前使用过的分支名/tag名/简写commit名到commit hash的最新映射关系。形如下方:
1 2 3
| { "master": "4e3a4089b4f0275964eb10a432dc1c15526a0b4d" }
|
这一步会尝试使用parse好的site
、user
、name
属性找已有的缓存的map.json
。没有找到时返回{}
。
获取commit hash
这一步分两种情况;
- 使用缓存时,直接从上一步拿到的
map.json
里面找ref
对应的commit hash
- 不使用缓存时,需要从远端仓库拿分支名/tag名到commit hash的对应关系(使用
git ls-remote
完成)。之后格式化为结构化数据并从中寻找ref
对应的commit hash。如果中途失败,则fallback到使用缓存的方式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| async function fetchRefs(repo) { try { const { stdout } = await exec(`git ls-remote ${repo.url}`);
return stdout .split('\n') .filter(Boolean) .map(row => { const [hash, ref] = row.split('\t');
}); } catch (error) { } }
|
这一步若未找到hash,则无法构造下载的url,从而需要抛出错误。
构造下载地址
根据不同的git平台固定的源码tar.gz归档url规则,构造下载的url,这也是degit思路的基础。目前支持gitlab、bucket、github风格的url。
1 2 3 4 5 6 7 8 9
| const file = `${dir}/${hash}.tar.gz`;
const url = repo.site === 'gitlab' ? `${repo.url}/repository/archive.tar.gz?ref=${hash}` : repo.site === 'bitbucket' ? `${repo.url}/get/${hash}.tar.gz` : `${repo.url}/archive/${hash}.tar.gz`;
|
创建目录并下载
不使用缓存时,会在创建缓存目录并下载。另外,指定-f
或--force
参数,会覆盖已有文件路径。最后使用https模块下载文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| try { if (!this.cache) { try { fs.statSync(file); } catch (err) { mkdirp(path.dirname(file)); await fetch(url, file); } } } catch (err) { }
|
更新缓存
下载成功会更新本地缓存,保证以后使用缓存时能使用尽量新的包。
- 当前使用包的commit hash如果和指定分支/tag/commit hash对应的hash一致,则不需要更新
- 在需要更新时,检查老的hash是否还有使用,如果没有使用,则清除hash对应的tar.gz包
- 更新map.json里的对应关系
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| function updateCache(dir, repo, hash, cached) { if (cached[repo.ref] === hash) return;
const oldHash = cached[repo.ref]; if (oldHash) { let used = false; for (const key in cached) { if (cached[key] === hash) { used = true; break; } }
if (!used) { try { fs.unlinkSync(path.join(dir, `${oldHash}.tar.gz`)); } catch (err) { } } }
cached[repo.ref] = hash; fs.writeFileSync( path.join(dir, 'map.json'), JSON.stringify(cached, null, ' ') ); }
|
解压tar.gz包
创建cli中输入的目标目录,并将已下载到缓存中tar.gz包解压到目标路径下。
1 2
| mkdirp(dest); await untar(file, dest);
|
actions
处理
如果在当前目录下获取到了degit.json
,则执行后续的clone
或remove
操作。
- clone,在目标目录下继续一遍clone流程
- remove,删除指定文件或文件夹
degit改造
degit虽好,但从上面也可以看到,支持仓库比较有限,且不支持私有仓库。在公司内部,无法从url推断git仓库类型时,degit就无法工作了。不过,借助degit本身的设计,稍微改造上面提到的“degit初始化”,“构造下载地址”部分,就可以让degit通过传参url风格的形式支持私有仓库。
- 新增
-s
或--style
命令行入参,表示git仓库url的风格,目前设计有github、gitlab、bitbucket这几个degit原始就支持的形式。
- 解析仓库地址信息时,若有style入参,则先判断是否在上述允许范围内;保留原有从域名解析style的部分,新增若未解析出style,则从入参里取;最后再抛出不支持的仓库地址错误
- 解析返回数据结构中,新增
style
字段表示url风格,原有的site
为避免歧义,直接使用域名代替原有的域名前缀
- 在构造下载地址时,直接根据style字段拼接url
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| function parse(src, style) {
if (style && !supportedGitStyle.has(style)) { throw new DegitError(`degit supports styles of github, gitlab, bitbucket`, { code: 'UNSUPPORTED_STYLE', }); } const site = match[1] || match[2] || match[3] || 'github.com'; const gitStyle = style || (match[1] || match[2] || match[3] || 'gitlab').replace(/\.(com|org)$/, ''); if (!supportedGitRepo.has(gitStyle)) { throw new DegitError( `degit supports GitHub, GitLab, Sourcehut and BitBucket without -s/--style parameters`, { code: 'UNSUPPORTED_HOST', } ); }
const user = match[4]; const name = match[5].replace(/\.git$/, ''); const ref = match[6] || 'master';
const url = `https://${site}/${user}/${name}`;
return { site, user, name, ref, url, style: gitStyle }; }
const url = repo.style === 'gitlab' ? `${repo.url}/repository/archive.tar.gz?ref=${hash}` : repo.style === 'bitbucket' ? `${repo.url}/get/${hash}.tar.gz` : `${repo.url}/archive/${hash}.tar.gz`;
|
可能存在的问题
绝大多数私有仓库,都会对用户身份做校验,直接访问tar.gz链接会报401错误。这需要根据不同的内部平台自己做处理了。
因为特殊原因,改造后的包和代码不提供。
–END–