degit认识和改造

背景

近日写了一个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

# these commands are equivalent
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只有cloneremove两种。

1
2
3
4
5
6
7
8
9
10
11
// degit.json
[
{
"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快在哪里?它的思路借鉴于zelgittar,即方便快捷地从git仓库中下载需要的源代码。原理上,利用某些git平台url的特定规则,从平台下载tar.gz包,再本地解压

degit实现集中在src/index.js中。src/bin.js只用来实现cli部分的入口代码,src/utils.js则包含了一些工具函数。

入口

src/bin.js中,流程分下面几步:

  1. 利用mri做了基本的参数处理
  2. 实例化Degit对象,注册logger的监听方法
  3. 调用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)
// .then(() => {

// })
.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)$/,
''
);
// 排除范围外的url
if (!supported.has(site)) {
throw new DegitError(
`degit supports GitHub, GitLab, Sourcehut and BitBucket`,
{
code: 'UNSUPPORTED_HOST',
}
);
}

// 匹配出用户名、仓库名、分支/tag/commit hash名
const user = match[4];
const name = match[5].replace(/\.git$/, '');
const ref = match[6] || 'master';

// 完整的仓库地址,需要http开头的
const url = `https://${site}.${
site === 'bitbucket' ? 'org' : site === 'git.sr.ht' ? '' : 'com'
}/${user}/${name}`;

return { site, user, name, ref, url };
}

仓库下载

下载仓库流程如下:

degit流程

获取缓存信息

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好的siteusername属性找已有的缓存的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
// 即将下载的tar.gz文件路径
const file = `${dir}/${hash}.tar.gz`;
// 下载的url
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) {
// 错误处理
// ...
}

更新缓存

下载成功会更新本地缓存,保证以后使用缓存时能使用尽量新的包。

  1. 当前使用包的commit hash如果和指定分支/tag/commit hash对应的hash一致,则不需要更新
  2. 在需要更新时,检查老的hash是否还有使用,如果没有使用,则清除hash对应的tar.gz包
  3. 更新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) {
// we no longer need this tar file
try {
fs.unlinkSync(path.join(dir, `${oldHash}.tar.gz`));
} catch (err) {
// ignore
}
}
}

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,则执行后续的cloneremove操作。

  • 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 };
}

// 拼接url处
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–