前言
在日常开发中我们每做一个功能需求
就会创建一个git功能分支
,时间久了本地和线上的分支就会被累积很多,那么此时有一个批量删除git分支的工具就显得尤为重要。GBKILL
正是为了解决这一需求也生的工具,让你更加高效的删除git分支 。 这篇文章主要讲述的是使用ink+react
构建批量删除git分支
的Node Cli
工具。
需求分析
在这里不再阐述脚手架的功能需求,可以通过需求规划进行查看
核心包介绍
在进行进行功能开发前,我们需要先了解一下会涉及到那些依赖包
- ink 使用react构建cli的基础包
- Commander: 强大的Node命令解析工具,其可以让我们更加简单的命令行参数
- simple-git: 在Node的程序使用
git
命令 - semver: version版本对比
- downgrade-root: 尝试降级具有root权限的进程的权限
- sudo-block: 阻止用户以 root 权限运行您的应用程序
- url-join: 拼接并且序列化urls
- figlet: 生成FIGfont字体
- colors: 命令行输出样式
项目结构图
为了能更加清晰的了解到项目中每个文件所负责的功能以及整个项目的结构,我使用了drawio
绘制了从初始化项目
->命令注册
->界面绘制
的视图
功能实现
项目初始化
因为这里我是基于react+ink
来开发,因此可以通过其提供的create-ink-app
脚手架来初始化项目模板并且选择指定typescrt类型。当然你也可以不选择他的模板自己主动创建一个,gbkill
也是后面才加入ink
因此也没有使用create-ink-app
创建
ts
npx create-ink-app --typescript gbkill
配置package.json
如下几个参数特别在这里特别标注一下,其余的可以直接看源码配置即可
ts
...
"bin": {
"gbkill": "./lib/index.js" // 指定脚手架命令 -> 执行命令映射到./lib/index.js文件
},
"scripts": {
"build": "yarn run clean:build && npx tsc", // 打包命令
"dev": "yarn run clean:build && npx tsc --watch", // 开发命令,启动时清除lib文件编译ts文件为js文件
"clean:build": "node --no-warnings=ExperimentalWarning --loader ts-node/esm ./scripts/clean-build.ts", // 通过脚本文件删除lib目录
},
"files": [
"lib", // 指定npm publish发布的文件,我们只需要把编译后的文件发布到npm社区中
],
入口文件声明(index.ts)
#! /usr/bin/env node
是什么意思呢? 就是从环境变量获取到node、并且使用Node运行该文件。等价于在项目根目录执行node index.js
命令。
当然我们也可以写成#! /usr/bin/node
。这种写法是直接执行/usr/bin
目录下的node,这种写法不推荐 因为这样子就把node固定位置了。但每个人的node安装目录会有所不同,所以推荐上面的#! /usr/bin/env node
写法
javascript
#!/usr/bin/env node
import main from './main.js';
main();
定义好入口时,执行yarn run dev
启动项目编译将生成lib目录。此时有两种方式调试,
第一种: 使用terminal进入lib目录执行./index.js文件
即可。
第二种: 在当前项目中使用npm link
将该项目link到全局中,随后在terminal中执行gbkill
即可
准备工作和命令监听入口(mian.ts)
项目初始化
采用微任务队列思维进行按顺序初始化
ini
init() {
let chain = Promise.resolve();
chain = chain.then(() => {
this.actions = new Actions();
});
chain = chain.then(async () => await this.prepare()); // 前期准备、检查版本、降级ROOT用户
chain = chain.then(() => this.registerCommand()); // 注册Command命令
chain = chain.then(() => this.exitListener()); // 注册退出监听
chain = chain.then(() => this.catchGlobalError()); // 捕获全局未知命令
chain.catch(error => {
console.log(colors.red(`🤡 ${error.message} 🤡`));
});
}
1. 准备阶段
在工具开始运行前,我们需要对版本、权限进行校验。 这一步骤必须在程序最开始的阶段
,因为你不能让用户都准备执行删除了,才告诉用户版本过低之类的错误信息
ts
async prepare() {
/**
* 1. Node版本
* 2. 降级root账户
* 3. 检查用户主目录
* 4. cli版本
*/
this.readPackage();
this.checkNodeVersion();
this.checkRoot();
this.checkUserHome();
await this.checkGlobalUpdate();
}
- 读取package信息
因为import导入package.json
还处于实验阶段的功能,所以采用readPackage
暂时替代import导入模式
ts
// 该功能属于试验性
// import pkg from '../package.json' assert { type: "json" };
readPackage() {
const filePath = new URL('../package.json', import.meta.url);
const json = readFileSync(filePath);
this.pkg = JSON.parse(json.toString());
}
- 尝试降级具有root权限的进程的权限,如果失败,则阻止访问权限
PS: 因为之后需要读写本地缓存,因此需要当前账号存在读写权限
javascript
import downgradeRoot from 'downgrade-root';
import sudoBlock from 'sudo-block';
// $ 尝试降级具有root权限的进程的权限,如果失败,则阻止访问权限
rootCheck() {
try {
downgradeRoot();
} catch {
//
}
sudoBlock();
}
checkUserHome() {
const home = userHome();
if (!(home && fs.existsSync(home))) {
throw new Error(
colors.red(
`The home directory for the current logged-in user does not exist`
)
);
}
}
- 检查gbkill本地和远程版本
我们可以通过https://registry.npmjs.org/gbkill
获取到gbkill
发布的版本信息。因为https://registry.npmjs.org/
属于国外镜像地址,因此我们在国内采用https://registry.npmmirror.com/gbkill
的方式 我们知道了如何获取到远程的npm包版本数据
,本地的版本又可以通过读取package.json
获取。紧接着使用semver
进行本地版本
和最新的远程版本
进行比较,不就可以选择性的使用视图提示用户是否需要更新了吗
ts
getNpmInfo(npmName: string) {
if (!npmName) {
return null;
}
// 国内环境可能访问外网会非常卡顿
const npmjs = urlJoin('https://registry.npmjs.org/', npmName);
const npmmirror = urlJoin('https://registry.npmmirror.com/', npmName);
const request = [axios.get(npmjs), axios.get(npmmirror)];
return Promise.race(request)
.then(response => {
...
})
.catch(() => {
...
});
}
async checkGlobalUpdate() {
...
// 3. 提取所有版本号,对比那些版本号是大于当前版本
if (lastVersion && semver.gt(lastVersion, currentVersion)) {
// 4. 获取最新的版本号,提示用户更新到该版本
this.actions!.lowerVersion(lastVersion, currentVersion);
}
}
在getNpmInfo采用Promise.race
发送多个请求使用最先返回数据的请求(https://registry.npmjs.org/
镜像在国内访问响应速度过于缓慢)。在这里只讲主流程至于具体代码实现可以直接看源码即可。Npm源码、checkGlobalUpdate
2. 注册命令
采用commander
命令行解析库,因为目前没有需要子命令的需求,因此我这里只注册了option
参数选项。当我们执行gbkill ...时,就会触发到.action
行为
ts
registerCommand() {
this.program
.name(this.pkg!.name)
.version(this.pkg!.version)
.description(this.pkg!.description)
.option('--force', 'Force deletion of branch')
.option('--sync', 'Synchronously delete remote branches')
.option('--merged <name>', 'Specify merged branch name')
.option('--lock <names...>', 'Lock branch')
.option('--unlock <names...>', 'Unlock a locked branch')
// TODO --submodule优先级降低
// .option('--submodule', '是否展示 git 子模块的分支列表')
// .option('--language <name>', '指定脚手架语言')
.action(args => this.actions!.gbkill(args));
// $ 监听未知命令
this.program.on('command:*', obj => {
console.error(
colors.red(`${this.pkg!.name}: Unknown commands ${obj[0]}`)
);
const availableCommands = this.program.commands.map(cmd => cmd.name());
if (availableCommands.length > 0) {
console.log(
colors.green(`Available commands: ${availableCommands.join(',')}`)
);
}
});
this.program.parse(process.argv);
}
3. 监听退出命令
监听程序退出时,清空之前打印的信息
并且打印感谢语句
ts
exitListener() {
process.on('beforeExit', code => {
this.actions!.exit(code);
process.exit(code);
});
}
4. 监听全局未捕获的错误
TODO: 未完成
Actions执行入口
在经过上一步骤我们完成了项目的前期准备
以及命令注册
,接下来来完成程序的主要逻辑功能。从上面Commander注册时可以看出,输入gbkill
命令后程序执行的是this.actions!.gbkill
方法
ts
...
.action(args => this.actions!.gbkill(args));
gbkill
方法主要完成的任务是分析命令参数
、参数存入到本地缓存中
、获取到当前项目的本地分支列表
、调用渲染逻辑
- 参数值持久化
ts
readEnvFile(): IEnv {
const home = userHome();
const filePath = path.join(home, DEFAULT_CLI_HOME);
let env: IEnv = {
MERGED_BRANCH: DEFAULT_MERGED_BRANCH,
LOCK: [],
LANGUAGE: DEFAULT_LANGUAGE as unknown as Language,
};
if (fs.existsSync(filePath)) {
const file = fs.readFileSync(filePath);
env = JSON.parse(file.toString());
} else {
fs.writeFileSync(filePath, JSON.stringify(env));
}
return env;
}
writeEnvFile(options: IWriteFile): IEnv {
const home = userHome();
const filePath = path.join(home, DEFAULT_CLI_HOME);
const cacheEnv = this.readEnvFile();
let lock = cacheEnv.LOCK.concat(options.lock);
const unlock = new Set(options.unlock);
if (unlock.size) {
// 去掉解锁的分支
lock = lock.filter(name => !unlock.has(name));
}
const env: IEnv = {
MERGED_BRANCH: options.merged ?? cacheEnv.MERGED_BRANCH,
LOCK: lock,
LANGUAGE: options.language ?? cacheEnv.LANGUAGE,
};
fs.writeFileSync(filePath, JSON.stringify(env));
return env;
}
async gbkill(args: Record<string, unknown>) {
const { ... } = args;
const env = this.writeEnvFile({ ... } as IWriteFile);
...
}
首先通过readEnvFile
读取本地缓存文件/用户主目录/.gbkill
文件,如果不存在.gbkill
文件先创建它并且给予初始值
。紧接将用户执行gbkill ~
的参数值二次处理之后替换掉本地的缓存.gbkill
的值。
- 获取到本地的git分支列表
ts
async gbkill(args: Record<string, unknown>) {
...
const branches = await this.git.getLocalBranches();
}
async getLocalBranches() {
...
const branchResult = await this.simpleGit.branchLocal();
...
}
通过调用simplet-git
提供的branchLocal
获取当前项目的git分支列表。
ts
getLocalBranches() {
...
const mergedBranches = await this.getMergedBranches();
}
async getMergedBranches(): Promise<Array<string>> {
const mergedBranch = this.gitOptions.merged || DEFAULT_MERGED_BRANCH; // 默认为main分支
try {
const branchResult = await this.simpleGit.branch([
'--merged',
mergedBranch,
]);
return branchResult.all;
} catch (error: any) {
if (~error.message.indexOf('malformed object name')) {
throw new Error(
`合并分支${mergedBranch}不存在,请通过--merged <name>设置`
);
} else {
throw new Error(error.message);
}
}
}
获取到列表之后我们还需要判断那些git分支
是已经并入了-- merged <name>
的分支。采用branch
方法并且指定参数[ '--merged', mergedBranch]获取分支合并信息。如果mergedBranch
不存在直接结束程序运行
ts
async getLocalBranches() {
const lock = new Set(this.gitOptions.lock);
...
const branches = Object.values(branchResult.branches)
.filter(branch => !lock.has(branch.name))
.map(branch => ({
name: branch.name,
value: branch.label,
merged: mergedBranches.includes(branch.name),
status: BRANCH_STATUS.NONE,
}));
return branches;
}
获取到分支列表
和是否合并信息
之后,我们需要隐藏掉被我们lock掉的分支
因此在这一步执行过滤操作即可
- 渲染列表
ts
// actions.ts
async gbkill(args: Record<string, unknown>) {
...
this.ui.render(branches, env.MERGED_BRANCH);
}
// ui/index.ts
clearConsole() {
// $ 因为ink的clear函数不生效,因此采用此方法来进行清空屏幕
// https://gist.github.com/timneutkens/f2933558b8739bbf09104fb27c5c9664
process.stdout.write('\u001b[3J\u001b[2J\u001b[1J');
console.clear();
}
render(branches: Array<any>, merged: string) {
this.clearConsole();
inkRender(
<Template
branches={branches}
merged={merged}
onEventTrigger={this.onEventTrigger.bind(this)}
/>
);
}
在获取到我们想要的git列表信息后,我就进入到了渲染列表
阶段也是react+ink
的用武之地。首先我们先清空terminal
显示的旧数据紧接着调用ink
的render
方法渲染我们编写的react风格的ink组件
UI组件层
经过上面的步骤,我们已经进入到了Template
组件的渲染
ts
...
const Template: React.FC<IList> = props => {
const { isRawModeSupported } = useStdin();
if (!isRawModeSupported) {
console.log(
colors.red(
`Oh no! GBkill does not support this terminal (TTY is required). This is a bug, which has to be fixed. Please try another command interpreter (for example, CMD in windows)`
)
);
}
....
return (
<Box flexDirection="column">
{isRawModeSupported ? (
<>
<Logo branchNumber={props.branches.length} />
<Box>
<Text backgroundColor="#C1FDB7" color="#040404">
{' >'} Space delete merge; Tab delete unmerged; RightArrow batch
selection {'< '}
</Text>
<Spacer />
<Text>merged</Text>
</Box>
...
</>
) : (
<Box>
<Text></Text>
</Box>
)}
</Box>
);
};
首先需要判断terminal
是否支持Raw
模型,如果不支持提示用户更换终端
。ink目前不支持window的git bash
。支持就渲染LOGO
和操作提示行
接下来编写List
组件
ts
const List: React.FC<IList> = props => {
const [branches, setBranches] = useState(props.branches);
const [rows] = useStdoutDimensions();
const [scrollHeight, setScrollHeight] = useState(0);
// *********************
// Life Cycle Function
// *********************
const scrollHeight = useMemo(() => {
// !!! 减去 数值 9,这个9是列表前面的行数. 解决选择时闪动问题,内容不能超过整体屏幕高度
return props.branches.length > rows - 9 ? rows - 9 : props.branches.length;
}, [rows, props.branches.length])
// *********************
// Service Function
// *********************
...
// *********************
// View
// *********************
...
return (
<Box flexDirection="column">
<ScrollArea
height={scrollHeight}
activeIndex={activeIndex}
maxLen={branches.length}
>
...
</ScrollArea>
</Box>
);
};
U组件这里不再阐述了,我们需要关注的我们需要自己写一个ScrollArea
滚动组件不能采用terminal
本身的滚动条且你的工具的视图的高度
不能高于terminal的高度
,否则在选择分支时
重渲染terminal会抖动
,这也就解释了scrollHeight
的高度需要 全屏幕-LOGO和空行的高度
ts
// List.tsx组件
const { range, activeIndex } = userInput(branches.length, { onSpace, onTab });
// userInput自定义hook
const userInput = (maxLen: number, eventTrigger: IEventTrigger) => {
// 批量选择的 - 基准下标
const [benchmark, setBenchmark] = useState(0);
const [isBatch, setIsBatch] = useState(false);
// 当前活动下标
const [activeIndex, setActiveIndex] = useState(0);
const [range, setRange] = useState<IRange>({ start: 0, end: 0 });
useEffect(() => {
const distance = range.end - range.start;
eventBus.emit(EVENT_TYPE.AMOUNT, distance + 1);
}, [range, eventBus]);
const updateRangeByIndex = (index: number) => {
if (isBatch) {
// 判断在基线的上还是下还是相等
if (index > benchmark) {
// start保持不变
setRange({ start: range.start, end: index });
} else if (index < benchmark) {
// end保持不变
setRange({ start: index, end: range.end });
} else {
setRange({ start: index, end: index });
}
} else {
setRange({ start: index, end: index });
}
};
const eventlistener = useCallback(
(input: string, key: Key) => {
if (key.upArrow && activeIndex > 0) {
// 向上选择
const index = activeIndex - 1;
updateRangeByIndex(index);
setActiveIndex(index);
} else if (key.downArrow && activeIndex < maxLen - 1) {
// 向下选择
const index = activeIndex + 1;
updateRangeByIndex(index);
setActiveIndex(index);
} else if (key.rightArrow) {
// 区间选择开关
if (!isBatch) {
// 设置批量选择的参照点
setBenchmark(range.start);
} else {
setRange({ start: activeIndex, end: activeIndex });
}
setIsBatch(!isBatch);
} else if (input === ' ') {
// 删除已merged分支
eventTrigger.onSpace(range);
} else if (key.tab) {
// 删除未merge分支
eventTrigger.onTab(range);
}
// TODO 条件有待商榷
},
[range, isBatch, benchmark, activeIndex]
);
useInput(eventlistener);
return { range, activeIndex };
};
export default userInput;
列表渲染完成之后,编写userInput
hooks来监听用户的控键按钮。这个也简单毕竟ink
给我们提供了useInput
方法用于监听用户输入。在此也感谢开源作者
提供这么好使用的工具包。
在这个hook我们需要处理的是: 记录当前活动的下标activeIndex
、被选中的起始下标
和结束下标
、监听space
和tab
按钮。
编写task任务
经过上面步骤我们已经得到了git列表
并且也监听了用户行为
。因为删除git分支
属于异步操作
且调用和执行不在同一个地方
,因此需要task辅助类
来完成这一操作
ts
import crypto from 'crypto';
class Task {
private queue: Map<string, (data: any) => void>;
constructor() {
this.queue = new Map();
}
createTask<T>(callback: (id: string) => void): Promise<T> {
const id = crypto.randomUUID();
return new Promise(resolve => {
this.queue.set(id, data => resolve(data));
callback(id);
});
}
getTaskById(id: string) {
return this.queue.get(id);
}
...
}
创建任务时声明Promise
,这个Promise的完成时在删除分支
之后。具体思路在这篇文章讲过了实现多个websocket串行请求
ts
// *********************
// Default Function
// *********************
const chianQueue = (
index: number,
status: BRANCH_STATUS,
message?: string
) => {
// 改变分支状态,进入到微任务状态
chian = chian.then(async () => {
setBranches(preBranches => {
const branches = JSON.parse(JSON.stringify(preBranches));
branches[index].status = status;
branches[index].message = message;
return branches;
});
// $ 添加过渡效果
await delay(50);
return Promise.resolve(null);
});
};
const delay = (ms: number) => {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
};
const deleteBranch = (range: IRange, action: Actions) => {
for (let i = range.start; i <= range.end; i++) {
const branch = branches[i];
const merged = action === Actions.TAB ? true : branch.merged;
const canDelete = ![
BRANCH_STATUS.DELETED,
BRANCH_STATUS.DELETING,
].includes(branch.status);
if (merged && canDelete) {
chianQueue(i, BRANCH_STATUS.DELETING);
task
.createTask<IBranchDeleteResult>(taskId => {
props.onEventTrigger(taskId, branch.name);
})
.then((res: IBranchDeleteResult) => {
chianQueue(i, res.status, res.message);
});
} else if (!merged && canDelete) {
chianQueue(
i,
BRANCH_STATUS.NO_MERGED,
`warn: The Branch is not merged into '${props.merged}'`
);
}
}
};
// 空格触发事件
const onSpace = (range: IRange) => {
deleteBranch(range, Actions.SPACE);
};
编写完task之后回到List
组件执行space
控件的执行,在这一步我们通过分支状态来决定是否执行删除操作
,例如正在删除
、已经删除
、未合并
不会调用删除逻辑
。分支可删除的情况先将分支状态改为正在删除状态
调用createTask
创建任务再回调中执行删除分支操作,然后等待删除完成.then
更改分支状态
ts
// git.ts
async deleteRemoteBranch(branchName: string) {
// git push origin --delete branch
await this.simpleGit.push('origin', branchName, ['--delete']);
}
async deleteLocalBranch(taskId: string, branchName: string) {
const branchResult: IBranchDeleteResult = {
branch: branchName,
status: BRANCH_STATUS.DELETING,
message: undefined,
};
try {
if (this.gitOptions.sync) {
// 同步删除远程分支
await this.deleteRemoteBranch(branchName);
}
const result = await this.simpleGit.deleteLocalBranch(
branchName,
this.gitOptions.force
);
if (result.success) {
branchResult.status = BRANCH_STATUS.DELETED;
} else {
branchResult.status = BRANCH_STATUS.FAILED;
}
} catch (error: any) {
const message = error.message.replace(/[\n|\r|\r\n]/g, ',');
if (~message.indexOf('git branch -D')) {
// 需要强制才可以删除
branchResult.status = BRANCH_STATUS.NO_FORCE;
} else if (
~message.indexOf('failed to push some refs') ||
~message.indexOf('Could not read from remote repository')
) {
// 删除远程分支出错
branchResult.status = BRANCH_STATUS.NO_SYNC;
} else {
// 未知失败
branchResult.status = BRANCH_STATUS.FAILED;
}
branchResult.message = message;
}
task.deleteError(branchName);
if (branchResult.status !== BRANCH_STATUS.DELETED) {
task.addError(branchName);
eventBus.emit(EVENT_TYPE.ERROR, task.getErrors());
}
const callback = task.getTaskById(taskId);
callback!(branchResult);
task.deleteTaskById(taskId);
}
上一步执行onEventTrigger
方法最终调用的函数就是deleteLocalBranch
方法。在此首先判断是否需要删除远程分支然后在删除本地分支,删除完成之后通过taskId
获取到任务中定义的回调并且执行callback!(branchResult);
即当前的任务就完成了它的整个生命周期
。
发布
-
版本
目前
gbkill
属于bate版本
等加上单元测试
即为正式版本。语义化版本可以参考这篇文章 -
发布
ts
npm version patch
npm run build
npm login
npm publish --access public
补充说明
经过上面的步骤我们就完成了批量删除git分支
主流程功能,具体代码都可以在这里查看、至于单元测试
和后续功能迭代
计划都在这里可以跟进
其他
- 源码地址欢迎大家提
PR
或者ISSUE
我将抽空持续维护它 - 如果对你有帮助也期待你的
star
,感谢你的阅读