批量删除Git分支

前言

在日常开发中我们每做一个功能需求就会创建一个git功能分支,时间久了本地和线上的分支就会被累积很多,那么此时有一个批量删除git分支的工具就显得尤为重要。GBKILL正是为了解决这一需求也生的工具,让你更加高效的删除git分支。 这篇文章主要讲述的是使用ink+react构建批量删除git分支Node Cli工具。

批量删除Git分支_第1张图片

需求分析

在这里不再阐述脚手架的功能需求,可以通过需求规划进行查看

核心包介绍

在进行进行功能开发前,我们需要先了解一下会涉及到那些依赖包

  • ink 使用react构建cli的基础包
  • Commander: 强大的Node命令解析工具,其可以让我们更加简单的命令行参数
  • simple-git: 在Node的程序使用git命令
  • semver: version版本对比
  • downgrade-root: 尝试降级具有root权限的进程的权限
  • sudo-block: 阻止用户以 root 权限运行您的应用程序
  • url-join: 拼接并且序列化urls
  • figlet: 生成FIGfont字体
  • colors: 命令行输出样式

项目结构图

为了能更加清晰的了解到项目中每个文件所负责的功能以及整个项目的结构,我使用了drawio绘制了从初始化项目->命令注册->界面绘制的视图
批量删除Git分支_第2张图片

功能实现

项目初始化

因为这里我是基于react+ink来开发,因此可以通过其提供的create-ink-app脚手架来初始化项目模板并且选择指定typescrt类型。当然你也可以不选择他的模板自己主动创建一个,gbkill也是后面才加入ink因此也没有使用create-ink-app创建

npx create-ink-app --typescript gbkill

配置package.json

如下几个参数特别在这里特别标注一下,其余的可以直接看源码配置即可

...
  "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写法

#!/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)

项目初始化

采用微任务队列思维进行按顺序初始化

  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. 准备阶段
在工具开始运行前,我们需要对版本、权限进行校验。 这一步骤必须在程序最开始的阶段,因为你不能让用户都准备执行删除了,才告诉用户版本过低之类的错误信息

  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导入模式

    // 该功能属于试验性
    // 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: 因为之后需要读写本地缓存,因此需要当前账号存在读写权限

    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的方式
    批量删除Git分支_第3张图片
    我们知道了如何获取到远程的npm包版本数据,本地的版本又可以通过读取package.json获取。紧接着使用semver进行本地版本最新的远程版本进行比较,不就可以选择性的使用视图提示用户是否需要更新了吗

    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
    批量删除Git分支_第4张图片

2. 注册命令
采用commander命令行解析库,因为目前没有需要子命令的需求,因此我这里只注册了option参数选项。当我们执行gbkill ...时,就会触发到.action行为

  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 ', 'Specify merged branch name')
      .option('--lock ', 'Lock branch')
      .option('--unlock ', 'Unlock a locked branch')
      // TODO --submodule优先级降低
      // .option('--submodule', '是否展示 git 子模块的分支列表')
      // .option('--language ', '指定脚手架语言')
      .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. 监听退出命令
监听程序退出时,清空之前打印的信息并且打印感谢语句

exitListener() {
    process.on('beforeExit', code => {
      this.actions!.exit(code);
      process.exit(code);
    });
}

批量删除Git分支_第5张图片

4. 监听全局未捕获的错误
TODO: 未完成

Actions执行入口

在经过上一步骤我们完成了项目的前期准备以及命令注册,接下来来完成程序的主要逻辑功能。从上面Commander注册时可以看出,输入gbkill命令后程序执行的是this.actions!.gbkill方法

...
.action(args => this.actions!.gbkill(args));

gbkill方法主要完成的任务是分析命令参数参数存入到本地缓存中获取到当前项目的本地分支列表调用渲染逻辑

  • 参数值持久化

    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) {
      const { ... } = args;
      const env = this.writeEnvFile({ ... } as IWriteFile);
      ...
    }

    首先通过readEnvFile读取本地缓存文件/用户主目录/.gbkill文件,如果不存在.gbkill文件先创建它并且给予初始值。紧接将用户执行gbkill ~的参数值二次处理之后替换掉本地的缓存.gbkill的值。

  • 获取到本地的git分支列表

     async gbkill(args: Record) {
       ...
       const branches = await this.git.getLocalBranches();
     }
     
    async getLocalBranches() {
      ...
      const branchResult = await this.simpleGit.branchLocal();
      ...
    }

    通过调用simplet-git提供的branchLocal获取当前项目的git分支列表。

    getLocalBranches() {
      ...
      const mergedBranches = await this.getMergedBranches();
    }
    
    async getMergedBranches(): Promise> {
      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 设置`
          );
        } else {
          throw new Error(error.message);
        }
      }
    }
    

    获取到列表之后我们还需要判断那些git分支是已经并入了-- merged 的分支。采用branch方法并且指定参数[ '--merged', mergedBranch]获取分支合并信息。如果mergedBranch不存在直接结束程序运行

    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掉的分支因此在这一步执行过滤操作即可

  • 渲染列表

    // actions.ts
    async gbkill(args: Record) {
      ...
      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, merged: string) {
      this.clearConsole();
      inkRender(