批量删除Git分支

前言

在日常开发中我们每做一个功能需求就会创建一个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显示的旧数据紧接着调用inkrender方法渲染我们编写的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;

列表渲染完成之后,编写userInputhooks来监听用户的控键按钮。这个也简单毕竟ink给我们提供了useInput方法用于监听用户输入。在此也感谢开源作者提供这么好使用的工具包。

在这个hook我们需要处理的是: 记录当前活动的下标activeIndex、被选中的起始下标结束下标、监听spacetab按钮。

编写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分支主流程功能,具体代码都可以在这里查看、至于单元测试后续功能迭代计划都在这里可以跟进

其他

  1. 源码地址欢迎大家提PR或者ISSUE我将抽空持续维护它
  2. 如果对你有帮助也期待你的star,感谢你的阅读
相关推荐
做前端的娜娜子32 分钟前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅39 分钟前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni1 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学2 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
赵庆明老师3 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
wc883 小时前
微软EDGE浏览器功能学习
前端·学习·edge
Csvn3 小时前
🎯 原生 `<dialog>` 元素:终于可以扔掉一半的自定义弹窗组件了?
前端
Csvn3 小时前
🎨 CSS @layer:用「层叠优先级」终结样式打架的世纪难题
前端
濮水大叔4 小时前
不必把 Vue3 写成“麻花”:从状态碎片到对象协作,重新理解 Zova 的前端心智模型
前端·typescript·vue3·ioc·tsx·zova