Git bisect 命令解析 #4 : 应用过程中的一些扩展问题

701 阅读4分钟

系列文章

引言

本系列的前三篇文章, 介绍了 git bisect 的基本原理, 并分析了三种不同类型的例子, 希望可以让你在各种场景下都能游刃有余地利用上 git bisect.

但是, 在真实的应用过程中, 很可能还会遇到一些疑问 :

  • 问题 1. 我就是任性, 我就不用 git bisect 自动推荐的提交, 是否可以主动切换另一个提交?
  • 问题 2. 如果很不幸, git bisect 选中的某个提交, 恰好无法被确认好坏 ("good commit" 或者 "bad commit"), 怎么办?
  • 问题 3. 如果更不幸的, 一大堆的提交都是无法确认好坏, 怎么办?
  • 问题 4. 我们是否有方法, 让 "机械地确认过程" 变得更加的高效?
  • 问题 5. 如果某个自动化的过程中, 又双叒叕遇到无法确认好坏的提交, 又该怎么办?
  • 问题 6. 如果某个确认过程比较复杂, 存在异步情况, 但是步骤稳定, 还有可能实现自动化么?

本章, 就让我们来看看这些形形色色的问题.

git bisect 过程中可能遇到的问题

无法确定提交的状况

  • 问题 1. 我就是任性, 我就不用 git bisect 自动推荐的提交, 是否可以主动切换另一个提交?
  • 问题 2. 如果很不幸, git bisect 选中的某个提交, 恰好无法被确认好坏 ("good commit" 或者 "bad commit"), 怎么办?

这两个问题, 可以被合并起来解答, 因为他们本质上是很相似的场景, 比如问题 2 中的情况, 实际上是因为 git bisect 推荐的 commit 不合适, 导致需要切换到其他的 commit, 和问题 1 的切换是很类似的, 只不过一种被动, 一种主动而已.

下面, 我们先解释一下问题 2 出现的原因.

比如说, 某个问题和 UI 界面上出现的问题相关, 并且, 你也并不确认与其直接关联的问题代码. 这时, 如果 git bisect 给你推荐了一个, 启动不起来看不到 UI 界面的 commit, 那么这时就很尴尬了.

另外, 即便是构建等过程都正常, 但如果有另外的因素干扰了出问题的现象, 导致无法确定或无法区分, 这些也可归属于不好确定的情况 ( untesable commits ).

还有, 就算一个 commit 费点周章还是能识别出好坏, 我们还是有可能想要跳过它, 因为... 懒 😂

关于这个情况, git bisect 提供了 skip 命令, 你可以通过执行 git bisect skip 直接忽略当前的 commit, 同时, 程序会自动再为你推荐另外一个 commit 进行确认. ( 关于 skip 的一些算法处理, 计划在后续的一篇文章中提及, 此处不做详细介绍 ), 这个是问题 2 的答案.

然后, 问题 1 呢? 我们能主动无视掉程序推荐的 commit, 手动选择一个 commit 进行确认么? 答案是可以的, 在 git bisect 的官方文档中, 有提及 "你可以手动选择一个周边的 commit, 并转而确认这个 commit (you can manually select a nearby commit and test that one instead)"

关于问题 1 扩展出来的一个满足好奇的问题, 如果我无视原有 git bisect 的前提要求, 胡乱切一个 commit, 然后随意标记一个不可能的状态, 如设置 bad commit 的某个后续提交为 good, 会发生什么事情?

程序会友好地抛你一脸提示, 并且无视你的后续正确的操作, 持续抛你一脸的提示...

image.png

无法确定提交状态, 并且这类提交连成一片

  • 问题 3. 如果更不幸的, 一大堆的提交都是无法确认好坏, 怎么办?

比如之前的场景里, 我们需要系统跑起来, 看到 UI 界面才能判断 commit 是好是坏, 但好死不死一个 commit 把系统构建给搞挂了, 并且还一挂挂一溜. 元芳, 你这次怎么看?

这种场景下, 有两种可能,

  • 第一种 : 首次出问题的提交, 没有 在无法确认的提交区域中
  • 第二种 : 首次出问题的提交, 刚刚好落在 无法确认的提交区域中

对于第一种情况其实还好, 基本上还是能正确定位到首次出问题的提交 (如下图). 另外, 虽然后面会计划再讨论 skip 命令的具体逻辑, 但这还是简单介绍一下, 在选择跳过 commit 时, skip 通过引入伪随机数调整权重, 来降低落入无法确认的提交范围的概率.

image.png

第二种情况, 就比较麻烦了, 如下图所示 :

image.png

由于无法确认 bad commit 紧接着的前置提交的状态, git bisect 无法获得所需信息, 故最后仅能给出一个可能的提交范围.

image.png

参考 git 官方文档, 对于这种类别的提交, 基本的处理思路是 :

  • 1) 确认有问题的范围,
    • 找到首次无法确认的提交, 记为 BBC (bisect breaking commit)
    • 找到后续首次解决问题的提交, 即为 BFC (bisect fixing commit)
    • 假定中间夹了 X1, X2, X3, X4 个提交.

image.png

  • 2) 通过处理, 把无法确认状态的提交 X1, X2, X3, X4 单独修复出来
    • 例如, 可以通过 git 的交互式 rebase, 把 BFC 先往前挪动, 并和 BBC squash 为一个可确认状态的提交 ( 这里假设, 这种改动, 会修复 squash 之后的提交, 使其状态变成可确认了 )
    • 接着, X1, X2, X3, X4 将被移出范围之外 (有可能会存在一定的 rebase 的冲突需要解决), 这些与原来提交所对应的新的提交序列, 记为 X1', X2', X3', X4'

image.png

  • 3) 通过上述处理, 后续在进行 git bisect 时, 如果选取得到 X1 ~ X4 的任意提交, 只需要使用 X1' ~ X4' 对应的提交来进行状态确认

当然, 这么做, 在提交的状态确认的时候, 还存在有一次显式的前置处理 ( 无论是人肉执行的, 还是脚本自动对应处理 patch ), 这么做还是略显笨拙. 实际上, git 官方文档中, 推荐了一种方式 git replace, 是一个非常有意思且实用的进阶方法, 它可以在不实际更改 git 代码仓库历史的情况下, 替换提交历史的内容或结构, 很值得额外了解一下, 本处就不再展开讨论.

git bisect 提效

  • 问题 4. 我们是否有方法, 让 "机械地确认过程" 变得更加的高效?

如果某个 "bug" 的特征被确定后, 基本还没明确根源, 大多数场景下, 可以找到标准化的 "确认好坏的流程". 而当一个流程足以被标准化时, 往往一个脚本就不难写了.

如果感觉对了, 脚本也被撸出来, 那么 git bisect 提供了一个非常使用的子命令 run, 用于将问题查找变成完全自动化的过程.

参考 git 文档中提供的一个例子, 假设某个代码仓库对应一个程序, 那么我们可以利用如下的命令, 来完成自动化查找 :

$ cat ~/test.sh
#!/bin/sh
make || exit 125                     # 如果无法 build 成功, 返回 125
~/check_test_case.sh                 # 一个另外的脚本,提供对构建后程序的运行验证

$ git bisect start HEAD HEAD~10 --   # 加装 bug 出现过在前面 10 次提交中

$ git bisect run ~/test.sh

$ git bisect reset                   # 完成退出

注意以上的特殊的返回码 125, 这个可以回答我们的问题 5

  • 问题 5. 如果某个自动化的过程中, 又双叒叕遇到无法确认好坏的提交, 又该怎么办?

按照 git 的文档, 如果 run 所执行的程序或脚本返回这个错误码 ( 125 ), 那么代表本次提交无法确认状态.

选取 125 是因为这个错误码是合适的错误码中最高的一个, 126127 被 POSIX shell 用来定义特定的出错状况 ( 125 was chosen as the highest sensible value to use for this purpose, because 126 and 127 are used by POSIX shells to signal specific error status. )

另外, 记得上述的例子中的 test.shcheck_test_case.sh 不要放置在代码仓库所属的文件夹下面, 否则运行时随着 commit 被切换, 这两个脚本是会被重置, 或出现问题的. ( 上述例子中, 是直接放置在了 Home 文件夹下 )

涉及异步检查的自动化处理

  • 问题 6. 如果某个确认过程比较复杂, 存在异步情况, 但是步骤稳定, 还有可能实现自动化么?

像 "看看某些文件的特定特征", "build 命令行, 运行产物看结果" 这种, 直觉上很容易处理成顺序执行的脚本的, 好像都挺简单.

但如果某个确认过程, 我们需要启动一个比较大型的 web 服务, 然后要在网页界面上做点小操作, 才能看到的话, 就比较不好处理成脚本.

对于这个场景, 可供参考的思路是 : 考虑用 "等待机制" 把异步过程, 变成一个阻塞的同步脚本执行

下面用比较具体的代码来演示 (以 NodeJS 的项目以及处理代码为例), 涉及的代码如下 :

  • server.js : 假设代码的运行, 会启动一个 web 服务, 这个用一个简易 server.js 来代替
  • detect.js : 用于等待 web 服务, 确认结果, 最后退出服务, 并返回对应的 status code

以上涉及的演示代码, 假设都是放在 Home 文件夹下, 用 node ~/server.js 可运行 ( 但是请注意 : 真实场景下, 运行 node ~/server.js 应被类似 npm install && npm start 等命令所代替 )

server.js 代码内容

const express = require('express');

function runServer() {
    const app = express();

    app.get('/', (req, rsp) => {
        // 模拟一个随机的好的或坏的结果
        const resultStatus = Math.random() < 0.5 ? 'correct' : 'wrong';
        rsp.send(`${resultStatus} result`);
    });

    setTimeout(() => {
        const port = 8881;
        app.listen(port, () => {
            console.log(`Server started at http://localhost:${port}`);
        });
    }, 3000); // 模拟运行了一段时间才启动
}

runServer();

detect.js 代码内容

小贴士 代码较长, 可以从下面的入口方法 main 开始看

const childProcess = require('child_process');
const axios = require('axios');

// 等待方法
function sleep(interval = 1000) {
    return new Promise((res) => {
        setTimeout(res, interval);
    });
}

/**
 * 正常的确认结束时的统一处理
 *
 * @param {'good' | 'bad' | 'skip'} bisectResult
 * @param {ChildProcess} childProcess
 */
function exitWithResult(
    bisectResult,
    childProcess = null
) {
    if (childProcess) {
        childProcess.kill()
    }
    console.log(`git bisect ${bisectResult}`);
    process.exit(
        bisectResult === 'good' ? 0
            : bisectResult === 'bad' ? 1
            // skip 的情况
            : 125
    );
}

async function main() {
    // step 1. 子进程中启动程序
    const child = childProcess.exec(`node ${__dirname}/server.js`);
    child.stdout.on('data', data => {
        console.log();
        console.log(data);
    });
    child.stderr.on('data', ex => {
        console.log();
        console.log(ex);
        
        // 启动过程发生错误的话, 当做 skip 处理
        exitWithResult('skip', child);
    });

    // 主动退出程序的情况下, 记得把子进程也清理掉
    process.on('SIGINT', () => {
        child.kill();
        // 注意, 当主动退出时, 我们要返回 > 127 的 exit code
        // 用于告知 git bisect, 这个退出是一种主动的结束行为, 而非标志为 `bad` 或 `skip`
        process.exit(128);
    });

    // step 2. 做一些检查
    let timeoutCount = 100;
    let bisectResult = 'skip';
    while (true) {
        await sleep(800);

        // 一个简易的超时机制, 发生超时时, 我们假定是 skip 的情况
        timeoutCount--;
        if (timeoutCount <= 0) {
            exitWithResult('skip', child)
        }

        try {
            const result = await axios.get('http://localhost:8881');

            // 结果确认
            if (result.data.includes('correct')) {
                exitWithResult('good', child);
            } else {
                exitWithResult('bad', child);
            }
            break;
        } catch (ex) {
            // 继续等待
            console.error('waiting for server', ex.message);
        }
    }
}

void main();

命令行运行

有了以上的脚本后, 我们切换到一个要做 git bisect 查找问题的代码仓库, 执行如下命令 :

$ cd path/to/my/repo

# 假设 bisect 的范围, 是当前提交以及之前的 20 个提交
$ git bisect HEAD HEAD~20

$ git bisect run node ~/detect.js

$ git bsect reset

然后, 就可以看到 git bisect 乐呵乐呵地自动跑起来了.

再进一步的思考 : 半自动化过程

有时候, 如果确认过程并不能很容易被明确为一个标准化过程 ( 实际上, 识别和设计标准的过程, 也是需要不少时间的 ), 那么全自动的 git bisect 就不合适了.

换句话说, 作为一个程序员, 我们也要适当地平衡好 "优化行为" 和 "手动执行" 的任务, 有时要避免对所有任务都要不分场合, 不计一切地进行优化 ( 比如在计划用 git bisect 找问题时, 忘记了 "找问题" 的原始目标, 而一味地沉浸在企图自动化查找过程这个衍生目标, 那就有些钻牛角尖了 ). 虽然, 追求卓越理应是流淌在我们的血液中, 但讲究时宜, 考虑影响也是必要的. 实际上, 我们完全可以在当时当刻先把问题给找了, 然后记一个后续的 todo, 后面得空时, 继续深入学习和研究. ( 这里好像扯的有点远, 请见谅 😃 )

不过, 这里既然提到, 我们不妨来设计一个相对通用, 且折衷的方案 :

即, 对于 git bisect 的过程, 假设确认问题一时不太容易处理成标准化的过程, 那么我们还是可以把大多数的机械操作给自动化了 ( 比如, 安装依赖, 设置程序, 启动服务等按部就班的操作 ), 然后一切妥当之后, 把实际确认的行为, 留给用户手动执行.

如下, 参考一个用于代替上述 detect.jsmanual-detect.js

const childProcess = require('child_process');

// inqurier 是一个很有意思的支持交互式命令行的第三方库
const inquirer = require('inquirer');

// 等待方法
function sleep(interval = 1000) {
    return new Promise((res) => {
        setTimeout(res, interval);
    });
}

/**
 * 正常的确认结束时的统一处理
 *
 * @param {'good' | 'bad' | 'skip'} bisectResult
 * @param {ChildProcess} childProcess
 */
function exitWithResult(
    bisectResult,
    childProcess = null
) {
    if (childProcess) {
        childProcess.kill();
    }
    console.log(`git bisect ${bisectResult}`);
    process.exit(
        bisectResult === 'good' ? 0
            : bisectResult === 'bad' ? 1
            // skip 的情况
            : 125
    );
}

async function main() {
    // step 1. 子进程中启动程序
    // 在很多场景中, 本程序只需替换以下的启动部分, 就可以直接复用了
    const child = childProcess.exec(`node ${__dirname}/server.js`);
    
    child.stdout.on('data', data => {
        console.log();
        console.log(data);
    });
    child.stderr.on('data', ex => {
        console.log();
        console.log(ex);
        // 启动过程发生错误的话, 当做 skip 处理
        exitWithResult('skip', child);
    });

    // 主动退出程序的情况下, 记得把子进程也清理掉
    process.on('SIGINT', () => {
        child.kill();
        process.exit(128);
    });

    // step 2. 接受等待用户的主动选择
    while (true) {
        await sleep(800);

        try {
            const rsp = await inquirer.prompt([
                {
                    name: 'bisectResult',
                    message: 'How does the commit look like?',
                    type: 'list',
                    choices: [
                        {name: 'good'},
                        {name: 'bad'},
                        {name: 'skip'},
                    ]
                }
            ]);

            exitWithResult(rsp.bisectResult, child);
        } catch (ex) {
            // 继续等待
            console.error('Something is wrong', ex.message);
        }
    }
}

void main();

除了中间过程, 需要手动确认一下提交的好坏, 命令执行和上面的自动化场景一致.

结语

git bisect 命令, 其应用部分, 基本就谈论至此.

虽然讲了不少, 但还是不可能把实际应用中遇到的所有情况都一一讨论. 但是, 学习本身也确实如此, 在任何时候, 我们并不会把所有可能涉及的知识或技能都练到满级, 再投入应用. 相对于具体的知识和技能而言, 学习的方法, 解决问题的思路, 开放而平衡的心态, 会更加的重要.

希望前几篇的文章, 已经能提供一些有用, 且足够可理解的样本, 帮助大家利用上一些工具, 让自己的研发生活更加轻松一些.

(本文完)

系列文章

参考

本文原作者 jsPop, 欢迎留言交流 🥳