仓库抓取与内容提取

Git 仓库抓取与内容提取:如何让AI读懂你的代码库?

本文是《InkWords:从代码到博客的智能转换器》系列的第15章。我们将深入剖析 GitFetcher 模块,看看它如何像一位细心的图书管理员,从Git仓库中提取、整理并分装代码文本,为后续的AI分析做好准备。

源码仓库https://github.com/2692341798/InkWords

一、为什么需要Git仓库解析?

在上一篇文章中,我们实现了单篇文档的智能转换。但在实际开发中,开发者面对的不是孤立的文件,而是完整的项目仓库。想象一下:

  • 新手学习:想要理解一个复杂的开源框架(如Vue.js、Spring Boot)
  • 项目复盘:需要为团队的新项目撰写技术文档
  • 代码审查:要快速掌握一个大型代码库的结构和设计

手动复制粘贴每个文件?那太痛苦了!GitFetcher 就是为解决这个问题而生。

二、GitFetcher的整体工作流程

让我们先通过一个流程图,直观地了解 GitFetcher 的完整工作流程:










开始: 接收Git仓库URL
创建临时目录
浅克隆仓库 depth=1
遍历文件系统
是目录吗?
是否忽略目录?

.git, node_modules等
跳过整个目录
继续遍历
是否忽略文件?

二进制/锁文件等
跳过文件
读取文件内容
内容是否有效?

UTF-8且非二进制
按目录聚合内容
目录内容超限?

300,000字符
智能分块
保持原样
返回分块结果
清理临时目录
结束: 返回结构树和代码块

这个流程的核心思想是:"阅后即焚"。我们临时克隆仓库,提取需要的信息,然后立即删除本地副本,确保不会占用用户磁盘空间。

三、代码逐行解析

现在,让我们深入 git_fetcher.go 的源代码,看看每个步骤是如何实现的。

3.1 核心数据结构

go 复制代码
// FileChunk represents a chunk of code, aggregated by directory or truncated if too large
type FileChunk struct {
    Dir     string  // 目录路径,如 "src/main/java"
    Content string  // 该目录下的所有代码内容
}

// GitFetcher is responsible for cloning a Git repository, extracting text from its files,
// and then deleting the cloned repository.
type GitFetcher struct{}

const maxChunkChars = 300000  // 单个代码块的最大字符数限制

解释

  • FileChunk:这是我们的"代码包裹"。每个包裹包含一个目录下的所有代码文本
  • maxChunkChars = 300000:这是关键参数!为什么是30万字符?因为大多数AI模型(如DeepSeek)有token限制(通常是128k)。30万字符大约对应10-15万token,留出了足够的空间给AI的回复

3.2 主流程:Fetch方法

go 复制代码
func (f *GitFetcher) Fetch(repoURL string) (string, []FileChunk, error) {
    // 1. 创建临时目录
    tempDir, err := os.MkdirTemp("", "inkwords-git-*")
    if err != nil {
        return "", nil, fmt.Errorf("failed to create temp directory: %w", err)
    }
    
    // 2. 保证临时目录会被删除("阅后即焚")
    defer os.RemoveAll(tempDir)

关键点

  • os.MkdirTemp("", "inkwords-git-*"):创建临时目录,名字以"inkwords-git-"开头
  • defer os.RemoveAll(tempDir):Go语言的defer确保函数结束时一定会执行删除,即使中途出错

3.3 克隆仓库

go 复制代码
    // 3. 克隆仓库(深度为1,只克隆最新提交,加快速度)
    cmd := exec.Command("git", "clone", "--depth", "1", repoURL, tempDir)
    var stderr bytes.Buffer
    cmd.Stderr = &stderr
    if err := cmd.Run(); err != nil {
        return "", nil, fmt.Errorf("failed to clone repository: %w, stderr: %s", err, stderr.String())
    }

为什么用 --depth 1

  • 我们只需要代码的当前状态,不需要完整的git历史
  • 这能显著减少克隆时间和磁盘占用
  • 对于大型仓库(如Linux内核),这可能是几分钟和几小时的区别

3.4 智能文件过滤

这是 GitFetcher 最智能的部分!我们不是盲目地读取所有文件,而是有选择地过滤:

go 复制代码
err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
    if err != nil {
        return err
    }
    
    // 4.1 跳过不需要的目录
    if info.IsDir() {
        dirName := info.Name()
        if dirName == ".git" || dirName == "node_modules" || dirName == "dist" || 
           dirName == "build" || dirName == ".idea" || dirName == ".vscode" || 
           dirName == "vendor" {
            return filepath.SkipDir  // 跳过整个目录
        }
        return nil
    }
    
    // 4.2 只处理普通文件
    if !info.Mode().IsRegular() {
        return nil
    }
    
    // 4.3 跳过依赖锁文件
    fileName := info.Name()
    if fileName == "package-lock.json" || fileName == "yarn.lock" || 
       fileName == "pnpm-lock.yaml" || fileName == "go.sum" || 
       fileName == "Cargo.lock" {
        return nil
    }
    
    // 4.4 跳过二进制文件
    ext := strings.ToLower(filepath.Ext(path))
    if IsBinaryExt(ext) {
        return nil
    }

过滤策略总结

  1. 目录级过滤:跳过构建产物、依赖包、IDE配置等
  2. 文件类型过滤:只处理普通文本文件
  3. 特定文件过滤:跳过锁文件(这些文件太大且对理解代码无帮助)
  4. 二进制文件过滤:通过扩展名识别

3.5 二进制文件识别函数

go 复制代码
func IsBinaryExt(ext string) bool {
    binaryExts := map[string]bool{
        // 可执行文件
        ".exe": true, ".dll": true, ".so": true, ".dylib": true,
        // 图片
        ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, 
        ".svg": true, ".ico": true, ".webp": true,
        // 压缩包
        ".zip": true, ".tar": true, ".gz": true, ".rar": true, ".7z": true,
        // 文档
        ".pdf": true, ".doc": true, ".docx": true, ".xls": true, 
        ".xlsx": true, ".ppt": true, ".pptx": true,
        // 媒体
        ".mp4": true, ".mp3": true, ".wav": true, ".avi": true, ".mov": true,
        // 字体
        ".ttf": true, ".woff": true, ".woff2": true, ".eot": true,
        // 编译产物
        ".pyc": true, ".class": true, ".jar": true, ".war": true,
    }
    return binaryExts[ext]
}

生活化比喻

想象你在整理一个杂乱的工具箱。IsBinaryExt 就像你的"物品分类器",它能快速识别出哪些是螺丝刀(文本文件,有用),哪些是已经生锈的旧零件(二进制文件,没用)。

3.6 内容读取与验证

go 复制代码
    // 5. 读取文件内容
    data, err := os.ReadFile(path)
    if err != nil {
        // 跳过无法读取的文件
        return nil
    }
    
    // 6. 验证内容是否为有效的UTF-8且不包含二进制数据
    if !utf8.Valid(data) || bytes.Contains(data, []byte{0}) {
        return nil
    }
    
    // 7. 按目录聚合内容
    dir := filepath.Dir(relPath)
    if dir == "." {
        dir = "/"  // 根目录用"/"表示
    }
    
    // 8. 单个文件内容截断(如果太长)
    contentStr := string(data)
    runes := []rune(contentStr)  // 使用rune计数,正确处理中文
    if len(runes) > maxChunkChars {
        contentStr = string(runes[:maxChunkChars]) + 
                    "\n\n... [File Content Truncated due to length limits] ..."
    }

重要细节

  • utf8.Valid(data):确保文件是有效的UTF-8编码
  • bytes.Contains(data, []byte{0}):检查是否包含NULL字节(二进制文件的标志)
  • 使用 []rune 而不是 len(string):这样能正确统计中文字符(一个中文字符是1个rune,但可能是3个字节)

3.7 智能分块策略

这是 GitFetcher 的另一个核心功能:当内容太多时,如何智能地分块?

go 复制代码
var chunks []FileChunk
for dir, builder := range dirContents {
    content := builder.String()
    runes := []rune(content)
    
    // 如果目录内容还是太大,进一步分块
    if len(runes) > maxChunkChars {
        numChunks := (len(runes) + maxChunkChars - 1) / maxChunkChars
        for i := 0; i < numChunks; i++ {
            start := i * maxChunkChars
            end := (i + 1) * maxChunkChars
            if end > len(runes) {
                end = len(runes)
            }
            chunks = append(chunks, FileChunk{
                Dir:     fmt.Sprintf("%s (Part %d/%d)", dir, i+1, numChunks),
                Content: string(runes[start:end]),
            })
        }
    } else {
        chunks = append(chunks, FileChunk{
            Dir:     dir,
            Content: content,
        })
    }
}

分块算法解析

  • numChunks := (len(runes) + maxChunkChars - 1) / maxChunkChars:这是经典的"向上取整"除法
  • 例如:如果内容有80万字符,maxChunkChars=30万,那么 (80+30-1)/30 = 109/30 = 3.63,向上取整为4块
  • 每块都有明确的标识:"src/main/java (Part 2/4)"

四、实战:自己动手运行GitFetcher

想要亲自体验 GitFetcher 的工作过程吗?以下是完整的可复现步骤:

步骤1:准备测试环境

bash 复制代码
# 1. 克隆InkWords项目
git clone https://github.com/2692341798/InkWords.git
cd InkWords

# 2. 确保已安装Go 1.19+
go version

# 3. 创建一个测试文件
cat > test_git_fetcher.go << 'EOF'
package main

import (
    "fmt"
    "log"
    "inkwords/backend/internal/parser"
)

func main() {
    fetcher := parser.NewGitFetcher()
    
    // 测试用的小型仓库(Go标准库的net/http包示例)
    repoURL := "https://github.com/golang/example.git"
    
    tree, chunks, err := fetcher.Fetch(repoURL)
    if err != nil {
        log.Fatal("Fetch failed:", err)
    }
    
    fmt.Println("=== 仓库结构 ===")
    fmt.Println(tree)
    
    fmt.Printf("\n=== 共生成 %d 个代码块 ===\n", len(chunks))
    for i, chunk := range chunks {
        fmt.Printf("块 %d: 目录=%s, 字符数=%d\n", 
            i+1, chunk.Dir, len([]rune(chunk.Content)))
        
        // 只显示前200个字符作为预览
        if len(chunk.Content) > 200 {
            fmt.Printf("预览: %.200s...\n\n", chunk.Content)
        } else {
            fmt.Printf("内容: %s\n\n", chunk.Content)
        }
    }
}
EOF

步骤2:运行测试(注意:需要修改导入路径)

由于项目结构原因,你需要稍微调整代码。在实际的InkWords项目中,GitFetcher 是通过服务调用的。但你可以理解其工作原理。

步骤3:理解输出结果

运行成功后,你会看到类似这样的输出:

复制代码
=== 仓库结构 ===
- hello/hello.go
- outyet/main.go
- stringutil/reverse.go
- template/main.go

=== 共生成 1 个代码块 ===
块 1: 目录=/, 字符数=2450
预览: --- File: hello/hello.go ---
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Hello is a trivial example of a main package.
package main

import (
    "fmt"
    "github.com/golang/example/stringutil"
)

func main() {
    fmt.Println(stringutil.Reverse("!selpmaxe oG ,olleH"))
}
...

五、设计思考:为什么这样设计?

5.1 为什么按目录分块,而不是按文件?

  • 保持上下文完整性:同一个目录下的文件通常有紧密的关联
  • 减少AI调用次数:每个目录作为一个完整的上下文单元
  • 符合开发者思维:我们通常按模块/目录理解项目结构

5.2 为什么设置30万字符限制?

  • AI模型限制:DeepSeek等模型通常有128k token限制
  • 成本控制:更长的输入意味着更高的API调用成本
  • 响应时间:过长的内容会导致AI响应变慢

5.3 "阅后即焚"模式的好处

  1. 安全性:不保留用户代码的本地副本
  2. 清洁性:不会占用磁盘空间
  3. 合规性:符合数据最小化原则

六、常见问题与解决方案

Q1: 如果仓库太大,克隆超时怎么办?

解决方案:可以添加超时控制

go 复制代码
cmd := exec.Command("git", "clone", "--depth", "1", repoURL, tempDir)
cmd.Stderr = &stderr

// 设置30分钟超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
cmd = exec.CommandContext(ctx, "git", "clone", "--depth", "1", repoURL, tempDir)

Q2: 如何支持私有仓库?

解决方案:需要添加认证支持

go 复制代码
// 在Fetch方法中添加认证参数
func (f *GitFetcher) Fetch(repoURL, username, token string) (string, []FileChunk, error) {
    // 将认证信息嵌入URL
    authedURL := fmt.Sprintf("https://%s:%s@%s", 
        username, token, strings.TrimPrefix(repoURL, "https://"))
    // ... 其余代码不变
}

Q3: 某些特殊文件格式(如.md, .txt)也被过滤了怎么办?

解决方案 :扩展 IsBinaryExt 函数的白名单

go 复制代码
func IsTextExt(ext string) bool {
    textExts := map[string]bool{
        ".md": true, ".txt": true, ".rst": true, 
        ".yml": true, ".yaml": true, ".toml": true,
        ".ini": true, ".cfg": true, ".conf": true,
    }
    return textExts[ext]
}

七、总结

GitFetcher 模块是InkWords项目从"单文件处理"迈向"完整项目分析"的关键一步。它展示了如何:

  1. 安全高效地处理外部数据源:通过临时目录和自动清理
  2. 智能过滤无关内容:像经验丰富的开发者一样知道什么该看、什么该忽略
  3. 为AI准备合适的输入:通过智能分块,确保每个上下文都是完整且大小合适的
  4. 保持系统可扩展性:设计清晰,易于添加新功能(如私有仓库支持、更多文件类型)

通过本章的学习,你应该已经掌握了:

  • GitFetcher 的完整工作流程
  • 如何识别和过滤二进制文件
  • 智能分块策略的设计原理
  • 如何在自己的项目中实现类似功能

下期预告:Map-Reduce 架构:智能拆分与并发分析

在下一篇文章中,我们将深入探讨InkWords如何将大型代码库的分析任务分解为多个小任务,并发执行,最后汇总结果。这是处理超大型项目的核心技术,敬请期待!

相关推荐
王码码203512 小时前
Go语言的测试:从单元测试到集成测试
后端·golang·go·接口
王码码203512 小时前
Go语言中的测试:从单元测试到集成测试
后端·golang·go·接口
真夜20 小时前
从Go工具到Vite插件:参考esbuild案例打造前端自动化部署神器
前端框架·node.js·go
Java水解21 小时前
Go语言中的Pool:对象复用的艺术
后端·go
Go_error2 天前
Go 并发控制 errgroup.Group
后端·go
GDAL2 天前
gin.H 深入全面讲解
gin·h
zs宝来了2 天前
etcd Raft 实现:分布式一致性核心原理
golang·go·后端技术
呆萌很2 天前
【Gin】参数处理练习题
gin
GDAL2 天前
gin.Default() 深入全面讲解
golang·go·gin