IntelliGit 智能分组暂存新增文件问题修复

本文为山东大学软件学院创新实训项目博客

IntelliGit 智能分组暂存新增文件问题修复

这次修的是 IntelliGit 提交工作区里一个比较隐蔽,但实际使用时很容易碰到的问题:智能分组暂存遇到"全部都是新增文件"的场景时,用户必须先手动把文件全部放进暂存区,系统才能分析出分组。

这个行为显然不符合智能分组暂存的设计目标。

理想流程应该是:

text 复制代码
工作区存在若干变更
点击智能分组分析
系统基于当前所有变更给出提交意图分组
用户选择某个分组
点击 √
系统清理当前暂存区
只把选中的分组放入暂存区
再基于该分组生成提交信息

也就是说,分析动作不应该要求用户先暂存。暂存应该发生在用户明确选择某个建议分组之后。

这篇博客记录一下这次问题是怎么定位的,以及最后为什么需要同时改 Sidecar 的 raw diff 能力和 Renderer 的智能提交 workflow。


一、问题表象:新增文件不先暂存就无法分析

最开始看到的现象很直接。

如果工作区里是普通修改文件,比如:

text 复制代码
M src/foo.ts
M src/bar.ts

点击智能分组按钮,系统可以正常读取 diff,然后进入 AI 分组或者本地 fallback 分组流程。

但是如果工作区里全是新增文件,而且这些文件还没有进入暂存区,比如:

text 复制代码
?? src/new-feature.ts
?? src/new-helper.ts

这个时候点击智能分组,系统会提示当前没有可分析的代码变更。

用户如果先手动执行"全部暂存",再点击智能分组,分析又能正常工作。

这说明问题并不是 AI 分组本身坏了,也不是提交面板没有拿到文件状态。真正的问题更像是:分析流程读取 diff 的方式看不到未跟踪文件。


二、为什么普通修改能分析,新增文件却不行

智能分组入口在 Renderer 里:

text 复制代码
src/renderer/src/services/smartCommitService.ts

分析函数会同时读取两个 raw diff:

ts 复制代码
const [workdirDiff, stagedDiff] = await Promise.all([
  invokeGit('diff.workdirRaw', {}),
  invokeGit('diff.stagedRaw', {})
])

旧逻辑会在两者之间选一个:

ts 复制代码
const diff = workdirDiff.diff || stagedDiff.diff

表面上看,这已经考虑了工作区和暂存区两种来源。但问题在于,diff.workdirRaw 的后端实现本质上调用的是:

bash 复制代码
git diff

而 Git 的默认行为是:git diff 只展示已跟踪文件的工作区修改,不展示 untracked 文件。

所以当工作区里只有未跟踪新增文件时:

bash 复制代码
git diff

输出是空的。

如果暂存区也为空:

bash 复制代码
git diff --staged

输出同样是空的。

最后 Renderer 得到的 diff 就是空字符串,于是智能分组流程只能认为"当前没有可分析的变更"。

这也解释了为什么用户手动全部暂存之后又能分析。因为新增文件进入 index 后,git diff --staged 就可以看到这些新增文件的 patch 了。


三、真正要修的不是按钮,而是 diff 能力

这个问题如果只从前端按钮入手,很容易做出一个看似能跑、但语义不对的方案。

比如在点击"分析分组"前自动执行:

text 复制代码
staging.addAll

这样确实能让新增文件进入 staged diff,也就能分析了。但这会把"分析"动作变成一个会修改暂存区的动作。

这和产品逻辑是冲突的。

分析应该是只读的。用户只是想看看系统建议怎么分组,不代表他已经决定要把所有文件放进暂存区。

所以正确的修复方向不是"分析前自动暂存",而是让工作区 raw diff 本身能够表达未跟踪新增文件。

换句话说,diff.workdirRaw 需要具备这样的能力:

text 复制代码
已跟踪文件修改 -> 来自 git diff
未跟踪新增文件 -> 额外合成 new file patch

这样分析阶段就可以读取完整工作区 diff,但不会修改真实暂存区。


四、Sidecar 层补齐 untracked 文件的 raw diff

后端相关代码在:

text 复制代码
sidecar/internal/git/staging_hunk.go

原来的 DiffWorkdirRaw 很简单:

go 复制代码
func (r *gitCliBackend) DiffWorkdirRaw(filePath string) (string, error) {
    args := []string{"diff"}
    if filePath != "" {
        args = append(args, "--", filePath)
    }

    output, err := r.runner.run(gitCliRunRequest{Dir: r.path, Args: args})
    if err != nil {
        return "", gitCliError("git diff 失败", output, err)
    }
    return output, nil
}

这次改完以后,它仍然保留 git diff 作为主来源,但会额外读取未跟踪文件:

go 复制代码
untrackedDiff, err := r.diffUntrackedFilesRaw(filePath)
if err != nil {
    return "", err
}
return joinRawDiffs(output, untrackedDiff), nil

未跟踪文件列表通过 Git 自己来获取:

bash 复制代码
git ls-files --others --exclude-standard -z

这里有几个细节。

第一,使用 --exclude-standard,这样 .gitignore.git/info/exclude 和全局 ignore 规则都会生效。否则一些不应该进入 Git 视野的构建产物、缓存文件也可能被智能分组读到。

第二,使用 -z,避免路径里存在空格或者特殊字符时被普通换行拆错。

第三,保留 filePath 过滤能力。因为后面点 √ 暂存某个分组时,会按分组文件逐个读取:

ts 复制代码
invokeGit('diff.workdirRaw', { path: filePath })

所以 Sidecar 层需要支持只为指定未跟踪文件生成 patch。


五、新增文件 patch 为什么要手动合成

未跟踪文件不在 index 里,也不在 HEAD 里。它对 Git 来说还不是"某个版本和另一个版本之间的差异",所以 git diff 默认不会输出它。

但对智能分组来说,一个新增文本文件完全可以表达成标准 unified diff:

diff 复制代码
diff --git a/new.txt b/new.txt
new file mode 100644
index 0000000..0000000
--- /dev/null
+++ b/new.txt
@@ -0,0 +1,2 @@
+hello
+world

这次 Sidecar 做的事情就是把未跟踪文件读出来,然后渲染成类似这样的 new file patch。

对于普通文本文件,会写出:

text 复制代码
diff --git
new file mode
index 0000000..0000000
--- /dev/null
+++ b/<path>
@@ -0,0 +1,N @@
新增行内容

对于二进制文件,不尝试把内容塞进文本 diff,而是输出:

text 复制代码
Binary files /dev/null and b/<path> differ

这可以让上层知道它是一个新增文件,同时避免把二进制内容误当成文本处理。

另外,为了避免路径穿越问题,读取未跟踪文件前还做了一层仓库相对路径校验:

go 复制代码
cleanPath := filepath.Clean(filepath.FromSlash(gitPath))
if cleanPath == "." ||
   cleanPath == ".." ||
   filepath.IsAbs(cleanPath) ||
   strings.HasPrefix(cleanPath, ".."+string(os.PathSeparator)) {
    return "", fmt.Errorf("invalid repository relative path: %s", gitPath)
}

这类校验看起来有点啰嗦,但放在 Sidecar 文件读取边界上是值得的。因为 raw diff 能力最终会触碰本地文件系统,路径必须保持在仓库内部。


六、Renderer 分析阶段不能再二选一 diff

Sidecar 能看到未跟踪文件以后,Renderer 这里也顺手修了一个旧逻辑。

原来分析阶段是:

ts 复制代码
const diff = workdirDiff.diff || stagedDiff.diff

这意味着只要工作区 diff 不为空,就会完全忽略暂存区 diff。

这在一些混合场景里不够准确。比如:

text 复制代码
暂存区里有 A 文件
工作区里有 B 文件

此时智能分组应该理解当前仓库里同时存在 A 和 B 两类变更,而不是只看工作区的 B。

所以这次改成合并两个 diff:

ts 复制代码
function combineDiffs(...diffs: string[]): string {
  const parts = diffs.map((diff) => diff.trimEnd()).filter((diff) => diff.trim().length > 0)
  return parts.length > 0 ? `${parts.join('\n')}\n` : ''
}

分析入口改成:

ts 复制代码
const diff = combineDiffs(workdirDiff.diff, stagedDiff.diff)

这样智能分组阶段读取的是"当前可见的全部变更上下文"。这一步仍然不修改暂存区,只是把两侧 diff 合并成一次分析输入。


七、点 √ 前为什么要先清空暂存区

另一个用户反馈也很关键:如果用户点击 √ 之前暂存区已经有东西,正确逻辑应该是什么?

旧逻辑是把所选分组继续 add 进去:

text 复制代码
已有暂存内容 + 所选分组内容

这在普通 Git 操作里可以理解,但在"按意图分组暂存"的语义里不对。

因为用户点击 √ 的含义不是"把这个分组追加到暂存区",而是"按照这个建议,把暂存区切换成这个分组"。

如果不先清空暂存区,就会出现这样的情况:

text 复制代码
暂存区原来有登录模块修改
智能分组建议选择 README 文档修改
点击 √ 后暂存区同时存在登录模块和 README
提交时把两个意图混在一起

这正好违背了智能分组的目标:帮助用户把不同提交意图拆开。

所以这次在 stageGroupAndGenerateMessage 里加了一步:

ts 复制代码
await unstageCurrentFiles()

unstageCurrentFiles 会先刷新状态,然后找出所有当前 staged 文件:

ts 复制代码
function getStagedFiles(): string[] {
  return normalizeFiles(useGitStatusStore.getState().fileStatuses.filter(isStagedFile).map((file) => file.path))
}

再逐个执行:

ts 复制代码
await invokeGit('staging.remove', { path: filePath })

这样点击 √ 的流程就变成:

text 复制代码
刷新状态
找出当前暂存文件
逐个取消暂存
读取所选分组的 workdir raw diff
按 hunk 或文件级暂存所选分组
刷新状态
读取 staged diff
生成该分组提交信息

这个顺序还有一个额外好处:如果某个候选分组文件之前已经被暂存,先取消暂存以后,它会重新回到工作区 diff 里。后面的分组暂存逻辑就可以统一从 workdir raw diff 里读取它,而不是在 staged 和 workdir 之间做复杂分叉。


八、为什么没有直接清空整个 index

这里还有一个实现选择:清空暂存区可以用逐个 staging.remove,也可以考虑提供一个"unstage all"命令。

这次选择了逐个 remove。

原因是当前 Sidecar 已经稳定提供了:

text 复制代码
staging.remove

它的语义是取消某个文件暂存,并保留工作区内容。这个语义正好符合智能分组暂存前的准备动作。

如果为了这次需求单独新增一个 staging.removeAll,需要同步更新:

text 复制代码
Sidecar command
handler contract
shared GitCommandMap
Renderer git client
service workflow
测试

这当然可以做,但对当前问题来说不是必要复杂度。智能分组暂存一次处理的文件数量通常也不会大到逐个 remove 成为瓶颈。

所以这次保持改动范围收敛,只复用已有命令。


九、补充测试:确保分析不会偷偷暂存

这次在 Sidecar 增加了一个回归测试文件:

text 复制代码
sidecar/internal/git/staging_hunk_test.go

核心测试场景是:创建一个未跟踪新文件,然后调用 DiffWorkdirRaw

期望结果有三点。

第一,workdir raw diff 能看到新增文件:

text 复制代码
diff --git a/new.txt b/new.txt
new file mode 100644
--- /dev/null
+++ b/new.txt
+hello
+world

第二,staged raw diff 仍然为空:

go 复制代码
stagedRaw, err := repo.DiffStagedRaw("")
if strings.TrimSpace(stagedRaw) != "" {
    t.Fatalf("workdir raw diff should not stage untracked files")
}

这点非常重要。因为它证明"分析能看到新增文件"不是通过偷偷 git add 实现的。

第三,文件状态仍然是 untracked:

go 复制代码
status := requireFileStatus(t, repo, path)
if status.Staging != StatusUntracked || status.Worktree != StatusUntracked {
    t.Fatalf("expected file to remain untracked")
}

这个测试直接覆盖了这次 bug 的核心约束:

text 复制代码
能分析未跟踪新增文件
但分析动作不能改变暂存区

另外还补了一个路径过滤测试,确保读取指定文件 diff 时,不会把其他未跟踪文件一起带进来。


十、验证结果

这次改完以后,跑了两类检查。

Sidecar Git 包测试:

bash 复制代码
go test ./internal/git

因为沙箱环境下默认 Go build cache 写用户目录会受限,所以实际验证时把 GOCACHE 指到了工作区内:

powershell 复制代码
$env:GOCACHE='E:\IntelliGit\sidecar\.go-build-cache'
go test ./internal/git

结果通过:

text 复制代码
ok intelligit-sidecar/internal/git

Renderer 类型检查:

bash 复制代码
npm.cmd run typecheck

结果也通过:

text 复制代码
typecheck:node passed
typecheck:web passed

期间也跑过 npm.cmd run lint,项目里仍然有一些既有的 Prettier warning 和 main process 侧的显式返回类型提示。这些不是这次智能分组修复引入的问题,所以没有在这次改动里顺手扩大范围处理。


十一、这次修复后的流程

现在智能分组暂存的行为可以概括成三段。

第一段,分析阶段:

text 复制代码
读取 workdir raw diff
其中包含普通工作区修改和未跟踪新增文件
读取 staged raw diff
合并两边 diff
交给智能分组 provider 分析

这一段不改变暂存区。

第二段,用户选择分组后点击 √:

text 复制代码
刷新当前 Git 状态
取消已有暂存文件
读取所选分组对应文件的 workdir raw diff
按 hunk 或整文件暂存分组内容

这一段会修改暂存区,但只在用户明确确认分组之后发生。

第三段,生成提交信息:

text 复制代码
读取所选分组暂存后的 staged diff
结合分组上下文生成 Conventional Commits 信息
回填提交输入框

这样一来,智能分组暂存的语义就比较完整了:

text 复制代码
分析是只读的
√ 是切换暂存区到所选分组
提交信息只基于所选分组生成

十二、这次问题带来的一个提醒

这次 bug 的根源其实不是一个很复杂的算法问题,而是一个 Git 行为细节:

text 复制代码
git diff 默认不包含 untracked 文件

这个细节在普通命令行使用里很常见,大家也能理解。但一旦把它放到"智能分析当前所有变更"的产品语义里,就会变成一个明显的断层。

对 IntelliGit 这种 Git 图形工具来说,底层 Git 命令的语义和上层产品语义之间,经常需要一层转换。

这次 diff.workdirRaw 的变化就是这种转换:

text 复制代码
底层 Git 语义:git diff 不看 untracked
产品分析语义:当前工作区新增文件也属于可分析变更
Sidecar 适配层:git diff + untracked new file patch

同样,点击 √ 前清空暂存区也是这种转换:

text 复制代码
底层 Git 语义:git add 是追加到 index
产品分组语义:选择分组是切换到该分组
Renderer workflow:先 unstage,再 stage selected group

所以这次修复让我更明确了一点:Git 客户端不能只是把 Git 命令原样包一层按钮。很多时候,真正需要设计的是"这个按钮在产品语义里代表什么",然后再决定底层应该怎样组合 Git 能力。


十三、后续可以继续优化的方向

这次修复以后,文件级智能分组的闭环更完整了,但后续还可以继续往两个方向推进。

第一个方向是 hunk 级分组。

当前分组主要还是围绕文件来做。如果一个文件里同时包含两个提交意图,系统仍然很难完全自动拆开。后续可以继续增强:

text 复制代码
raw diff hunk 解析
AST ownerLabel
hunk 与提交意图的映射
局部 applyPatch 暂存

第二个方向是更明确的暂存区保护提示。

现在点击 √ 会先清空已有暂存区,这是符合智能分组语义的。但从用户体验上看,如果暂存区原本已经有内容,未来可以在 UI 上给一个更明确的提示,例如:

text 复制代码
将替换当前暂存区为所选分组

这样用户会更容易理解这个按钮不是普通 Git add,而是语义化暂存动作。


十四、总结

这次修复表面上是"新增文件不先暂存就无法智能分组",实际拆开以后包含两个关键点。

第一个关键点是 diff 输入。智能分析需要看到当前所有变更,所以 Sidecar 的 diff.workdirRaw 不能只等价于 git diff,还要补齐未跟踪新增文件的 new file patch。

第二个关键点是暂存语义。用户点击 √ 选择一个智能分组时,系统不应该把这个分组简单追加到已有暂存区,而应该先取消当前暂存,再只暂存所选分组。

修完之后,流程终于更接近最初设计的"语义化暂存":

text 复制代码
先分析
再选择
再暂存
再提交

分析阶段不污染暂存区,确认阶段才改 index;新增文件不需要用户先手动暂存,已有暂存内容也不会意外混进所选分组提交里。

这类修复不一定有很大的界面变化,但它会让整个工作流的语义更可靠。对一个帮助用户组织提交的 Git 工具来说,这种可靠性其实非常关键。