找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1385

积分

0

好友

177

主题
发表于 2026-2-11 07:09:29 | 查看: 37| 回复: 0

想象这样一个场景:你的团队同时使用 Cursor、GitHub Copilot、Claude Code 等多种 AI 编程工具,每天都会产生大量由 AI 生成的代码。但是,你如何准确地知道 Git 仓库里最终有多少代码是 AI 生成的?又有多少 AI 生成的代码被真正“采纳”并保留了下来?

传统的 Git 只会记录提交者信息,却无法区分每一行代码的「真实作者」是人还是机器。而各家 AI 工具都有自己独立的会话管理方式,无法跨工具进行统一的追踪。这正是 git-ai 这个工具所要解决的核心问题。

为什么值得深入研究它的源码?

  1. 架构精妙:它巧妙利用 Git Plumbing 命令和 Git Notes 机制,实现了一个完全 Git-native 的 AI 代码追踪系统,性能开销控制在 100ms 以内。
  2. 多平台适配:它如何做到支持 Cursor、Claude Code、GitHub Copilot 等 10 多种 AI 工具的统一接入?
  3. Rust 工程化实践:如何利用 Rust 的异步、零成本抽象和强大的类型系统,实现一个高性能的 Git 代理层?

快速上手指南

项目地址:https://github.com/git-ai-project/git-ai

如果你想在深入源码之前先体验一下,可以按以下步骤快速上手:

# 1. 一键安装 (Mac/Linux/WSL)
curl -sSL https://usegitai.com/install.sh | bash

# 2. 安装 IDE 钩子 (自动检测并配置 Cursor、VSCode、Copilot 等)
git-ai install-hooks

# 3. 正常使用 AI 工具编写代码,然后 commit
git commit -m "Add feature with AI assistance"

# 4. 查看 AI 代码归属 (类似 git blame,但显示 AI 信息)
git-ai blame <文件路径>

# 5. 查看统计信息
git-ai status

安装完成后,git-ai 就像一个“隐形人”在后台默默记录。你可以继续使用 git commitgit rebasegit merge 等所有 Git 命令,git-ai 会自动处理 AI 归属信息在这些操作间的传播。


一、核心架构图解

设计思想

git-ai 的核心设计可以概括为三个关键点:

  1. Git Wrapper 模式:将 git-ai 安装为 Git 的代理,拦截所有 Git 命令,在 commit、rebase、merge 等关键节点插入自定义的 Hook。
  2. Git Notes 存储:利用 Git 原生的 Notes 机制(存储在 refs/notes/ai 引用下)来保存 AI 归属信息,避免污染 commit 历史。
  3. Checkpoint 机制:引入“检查点”概念,在 AI 或人类每次编辑后创建快照,最终在 commit 时合并为完整的归属日志。

核心流程图

git-ai AI代码追踪核心流程时序图


二、核心源码解读

01、Git Proxy 的优雅实现 —— 如何无缝拦截 Git 命令

位置src/commands/git_handlers.rs

核心代码 (为便于理解已简化,实际代码更复杂):

// src/commands/git_handlers.rs (简化版)
pub fn handle_git(args: &[String]) {
    let mut parsed_args = parse_git_cli_args(args);
    let mut repository_option = find_repository(&parsed_args.global_args).ok();

    let config = config::Config::get();
    let skip_hooks = !config.is_allowed_repository(&repository_option);

    // === 关键设计 1: Pre-hook 拦截 ===
    let exit_status = if !parsed_args.is_help && repository_option.is_some() && !skip_hooks {
        let repository = repository_option.as_mut().unwrap();

        // 性能监控: 记录 pre-hook 耗时
        let pre_command_start = Instant::now();
        run_pre_command_hooks(&mut command_hooks_context, &mut parsed_args, repository);
        let pre_command_duration = pre_command_start.elapsed();

        // === 关键设计 2: 透明代理到真实 Git ===
        let git_start = Instant::now();
        let exit_status = proxy_to_git(&parsed_args.to_invocation_vec(), false);
        let git_duration = git_start.elapsed();

        // === 关键设计 3: Post-hook 处理 ===
        let post_command_start = Instant::now();
        run_post_command_hooks(&mut command_hooks_context, &parsed_args, exit_status, repository);
        let post_command_duration = post_command_start.elapsed();

        // 性能监控: 如果总耗时超过阈值,记录日志
        log_performance_target_if_violated(
            parsed_args.command.as_deref().unwrap_or("unknown"),
            pre_command_duration, git_duration, post_command_duration
        );

        exit_status
    } else {
        proxy_to_git(&parsed_args.to_invocation_vec(), false)
    };

    exit_with_status(exit_status);
}

// 透明代理的核心实现 (实际代码处理了更多细节)
fn proxy_to_git(args: &[String], exit_on_completion: bool) -> ExitStatus {
    #[cfg(unix)]
    {
        // 交互式终端检测: TTY 模式下不创建新进程组,避免 SIGTTIN/SIGTTOU 挂起
        let is_interactive = unsafe { libc::isatty(libc::STDIN_FILENO) == 1 };
        let should_setpgid = !is_interactive;

        let mut cmd = Command::new(config::Config::get().git_cmd()); // 不是硬编码"git"
        cmd.args(args);

        unsafe {
            cmd.pre_exec(move || {
                if should_setpgid {
                    let _ = libc::setpgid(0, 0);  // 创建新进程组
                }
                Ok(())
            });
        }

        let (mut child, setpgid) = cmd.spawn().expect("Failed to execute git");

        // 如果创建了新进程组,安装信号转发处理器
        if setpgid {
            CHILD_PGID.store(child.id() as i32, Ordering::Relaxed);
            install_forwarding_handlers();  // 转发 SIGTERM/SIGINT/SIGHUP/SIGQUIT
        }

        let status = child.wait().expect("Failed to wait for git");

        // 清理信号处理器
        if setpgid {
            CHILD_PGID.store(0, Ordering::Relaxed);
            uninstall_forwarding_handlers();
        }

        status
    }

    #[cfg(windows)]
    {
        let mut cmd = Command::new(config::Config::get().git_cmd());
        cmd.args(args);
        if !is_interactive_terminal() {
            cmd.creation_flags(CREATE_NO_WINDOW);  // 非交互模式隐藏窗口
        }
        let mut child = cmd.spawn().expect("Failed to execute git");
        child.wait().expect("Failed to wait for git")
    }
}

解读

  1. Wrapper 而非 Fork:git-ai 没有重新实现 Git 逻辑,而是作为一个透明的“中间人”。用户执行 git commit,实际调用的是 git-ai commit,后者再调用真正的 git。这保证了:
    • 高兼容性:所有 Git 功能都能正常使用。
    • 零侵入性:不需要修改 Git 本身。
  2. 信号转发的魔法
    • 在 Unix 系统上,如果用户按 Ctrl+C 中断 Git 命令,git-ai 需要把信号正确转发给子进程。
    • 但有一个例外:「交互式终端」(TTY 模式)不能创建新进程组,否则会收到 SIGTTIN/SIGTTOU 导致挂起。
    • 代码通过 libc::isatty() 检测是否为交互式终端,只在非交互模式创建新进程组。
    • 如果创建了新进程组,会安装全局信号处理器,转发 SIGTERM/SIGINT/SIGHUP/SIGQUIT 到子进程。
  3. 跨平台处理
    • Unix:使用 pre_exec 钩子设置进程组,安装信号处理器。
    • Windows:使用 CREATE_NO_WINDOW flag 隐藏窗口(非交互模式)。
    • Git 命令路径通过 config::Config::get().git_cmd() 获取,支持用户自定义。
  4. 性能监控
    • 每个 Git 命令都会记录 pre-hook、git 执行、post-hook 的耗时。
    • 如果总耗时超过目标值(100ms),会记录性能日志用于优化。

启示

  • Wrapper 模式的威力:不需要重写整个系统,在外面包一层代理就能扩展功能,同时保障兼容性。
  • 交互式 vs 非交互式:进程管理要区分场景,TTY 模式和后台运行的信号处理逻辑完全不同。
  • 性能监控的重要性:每个 Hook 都记录耗时,发现性能瓶颈后才能优化。

02、Checkpoint 机制 —— 如何精准追踪“这行代码是谁改的”

位置src/commands/checkpoint.rs

核心代码 (为便于理解已简化,省略了 INITIAL attributions 处理等复杂逻辑):

// src/commands/checkpoint.rs (简化版)
// 实际函数签名有 11 个参数,这里只展示核心逻辑
fn get_checkpoint_entry_for_file(
    file_path: String,
    ……
    ts: u128,
) -> Result<Option<(WorkingLogEntry, FileLineStats)>, GitAiError> {
    let current_content = working_log.read_current_file_content(&file_path)?;

    // === 关键设计 1: 获取文件的“上一个状态” ===
    let from_checkpoint = previous_checkpoints.iter().rev().find_map(|checkpoint| {
        checkpoint.entries.iter()
            .find(|e| e.file == file_path)
            .map(|entry| {
                (
                    working_log.get_file_version(&entry.blob_sha).unwrap_or_default(),
                    entry.attributions.clone(),
                )
            })
    });

    let (previous_content, prev_attributions) = if let Some((content, attrs)) = from_checkpoint {
        // 文件在之前的 checkpoint 中存在,直接使用
        (content, attrs)
    } else {
        // === 关键设计 2: 从 Git HEAD 重建初始状态 ===
        let previous_content = if let Some(tree_id) = head_tree_id.as_ref().as_ref() {
            let head_tree = repo.find_tree(tree_id.clone()).ok();
            if let Some(tree) = head_tree {
                match tree.get_path(std::path::Path::new(&file_path)) {
                    Ok(entry) => {
                        if let Ok(blob) = repo.find_blob(entry.id()) {
                            String::from_utf8_lossy(&blob.content().unwrap_or_default()).to_string()
                        } else {
                            String::new()
                        }
                    }
                    Err(_) => String::new(),  // 新文件
                }
            } else {
                String::new()
            }
        } else {
            String::new()
        };

        // 如果文件内容没变,跳过
        if current_content == previous_content && initial_attrs_for_file.is_empty() {
            return Ok(None);
        }

        // === 关键设计 3: 用 git blame 反推历史归属 ===
        let mut prev_line_attributions = initial_attrs_for_file.clone();

        // 配置 blame 选项
        let mut ai_blame_opts = GitAiBlameOptions::default();
        ai_blame_opts.no_output = true;
        ai_blame_opts.return_human_authors_as_human = true;
        ai_blame_opts.use_prompt_hashes_as_names = true;

        // 根据 feature flag 决定是否运行 blame
        let ai_blame = if Config::get().get_feature_flags().inter_commit_move {
            repo.blame(&file_path, &ai_blame_opts).ok()
        } else {
            None // 跳过 blame,所有行默认为 human
        };

        if let Some((blames, _)) = ai_blame {
            for (line, author) in blames {
                if author != CheckpointKind::Human.to_str() {
                    prev_line_attributions.push(LineAttribution {
                        start_line: line,
                        end_line: line,
                        author_id: author.clone(),
                        overrode: None,
                    });
                }
            }
        }

        // 将行级归属转换为字符级归属
        let prev_attributions = line_attributions_to_attributions(
            &prev_line_attributions,
            &previous_content,
            INITIAL_ATTRIBUTION_TS
        );

        (previous_content, prev_attributions)
    };

    // === 关键设计 4: 计算当前状态与上一状态的差异 ===
    let tracker = AttributionTracker::new();

    // 先填充未归属的范围
    let filled_prev_attributions = tracker.attribute_unattributed_ranges(
        &previous_content,
        &prev_attributions,
        &CheckpointKind::Human.to_str(),
        ts - 1,
    );

    // 使用 imara-diff 算法计算行级变更
    let new_attributions = tracker.update_attributions(
        &previous_content,
        ¤t_content,
        &filled_prev_attributions,
        author_id.as_ref(),
        ts,
    )?;

    let line_attributions = attributions_to_line_attributions(&new_attributions, ¤t_content);

    // 计算文件的行统计信息
    let line_stats = compute_file_line_stats(&previous_content, ¤t_content);

    let entry = WorkingLogEntry::new(
        file_path,
        file_content_hash,
        new_attributions,
        line_attributions,
    );

    Ok(Some((entry, line_stats)))
}

解读

  1. 增量计算的智慧
    • 第一次遇到文件时,用 git blame 反推历史归属(谁在之前的 commit 改过这行)。
    • 后续编辑时,只需要基于上一个 checkpoint 的状态做 diff,避免重复计算。
    • 这就像 Git 本身的“快照 + 增量”设计,性能极高。
  2. Feature Flag 控制 Blame 行为
    let ai_blame = if Config::get().get_feature_flags().inter_commit_move {
        repo.blame(&file_path, &ai_blame_opts).ok()
    } else {
        None // 跳过 blame,所有行默认为 human
    };
    • inter_commit_move flag 控制是否使用 git blame 反推历史归属。
    • 关闭时,所有历史行都标记为 human,性能更好但会丢失跨 commit 的 AI 归属追踪。
    • 开启时,可以检测到用户复制粘贴 AI 代码的情况。
  3. 行级归属 vs 字符级归属:代码中有 LineAttributionAttribution 两种数据结构:
    为什么需要两种?因为当文件内容变化时,「行号会变,但字符偏移更稳定」(配合 diff 算法)。最终输出时再转回行号给用户看。
    • LineAttribution: 给人看的(第 10-15 行是 AI-xxx 写的)。
    • Attribution: 内部使用的字符范围(第 120-450 个字符是 AI-xxx 写的)。
  4. AttributionTracker 的核心算法:这是整个系统最复杂的部分,它要解决一个难题——「当文件被编辑后,如何更新每个字符的归属?」
    举个例子:

    # 初始状态 (全是 AI-session-123 写的)
    def hello():
        print("Hello")
    
    # 用户在中间插入一行 (human 写的)
    def hello():
        name = "World" # 这行是人类加的
        print("Hello")

    AttributionTracker 会:

    • imara-diff 算法找出变更点(插入了 name = "World"\n)。
    • 旧内容的归属被拆分成三段: [AI: 0-24][AI: 0-24, Human: 24-43, AI: 43-end]
    • 保留所有历史作者信息,用于后续的 git-ai blame 查询。

启示

  • 增量计算是性能的关键:基于上一个 checkpoint 做 diff,避免重复计算,提升性能。
  • 数据结构要为业务服务:行号给人看(LineAttribution),字符偏移给算法用(Attribution),各司其职。
  • Feature Flag 控制复杂度inter_commit_move flag 让用户在性能和准确性之间权衡。
  • 善用成熟算法imara-diff 实现了 Git 的 Myers diff 算法,不要重复造轮子。

03、多工具适配 —— 如何统一支持 10+ 种 AI 工具

位置src/commands/install_hooks.rsagent-support/vscode/src/extension.ts

核心代码 (Rust 侧)

pub fn run(args: &[String]) -> Result<HashMap<String, String>, GitAiError> {
    let binary_path = get_current_binary_path()?;
    let params = HookInstallerParams { binary_path };

    // === 关键设计 1: 插件式架构 ===
    let installers = get_all_installers();  // 返回所有支持的 AI 工具安装器

    for installer in installers {
        let name = installer.name();  // "Cursor", "Claude Code", "GitHub Copilot"

        // 检测工具是否安装
        match installer.check_hooks(¶ms) {
            Ok(check_result) if check_result.tool_installed => {
                // === 关键设计 2: 动态安装钩子 ===
                match installer.install_hooks(¶ms, dry_run) {
                    Ok(Some(diff)) => {
                        println!("{}: Hooks updated", name);
                        // 修改了 VSCode 的 settings.json 或 Cursor 的配置文件
                    }
                    Ok(None) => {
                        println!("{}: Already up to date", name);
                    }
                    Err(e) => eprintln!("{}: Failed - {}", name, e),
                }
            }
            _ => continue,  // 工具未安装,跳过
        }
    }

    Ok(statuses)
}

核心代码 (VSCode 插件侧)

// agent-support/vscode/src/ai-edit-manager.ts
export class AIEditManager {
    private snapshotOpenEvents = new Map<string, {
        timestamp: number;
        count: number;
        uri: vscode.Uri;
      }>();

    // === 关键设计 3: 捕获 Snapshot 打开事件 ===
    public handleOpenEvent(doc: vscode.TextDocument): void {
        // VSCode Copilot/Claude 在生成代码时会先打开一个特殊的 snapshot 文档
        if (doc.uri.scheme === "chat-editing-snapshot-text-model" ||
            doc.uri.scheme === "chat-editing-text-model") {
            const filePath = doc.uri.fsPath;
            const now = Date.now();

            this.snapshotOpenEvents.set(filePath, {
                timestamp: now,
                count: (this.snapshotOpenEvents.get(filePath)?.count || 0) + 1,
                uri: doc.uri  // 保存 URI,稍后解析 sessionId
              });

            // 在 AI 编辑前触发一次 human checkpoint (记录基准状态)
            console.log('[git-ai] Snapshot open event detected, triggering human checkpoint');
            this.triggerHumanCheckpoint([filePath]);
        }
      }

    // === 关键设计 4: 文件保存时检查是否为 AI 编辑 ===
    public handleSaveEvent(doc: vscode.TextDocument): void {
        const filePath = doc.uri.fsPath;

        // Debounce: 300ms 后评估
        setTimeout(() => {
            this.evaluateSaveForCheckpoint(filePath);
        }, 300);
      }

    private evaluateSaveForCheckpoint(filePath: string): void {
        const snapshotInfo = this.snapshotOpenEvents.get(filePath);

        // 如果有对应的 snapshot 打开事件 → 这是 AI 生成的
        if (snapshotInfo && snapshotInfo.count >= 1 && snapshotInfo.uri?.query) {
            const snapshotAge = Date.now() - snapshotInfo.timestamp;

            // 检查 snapshot 是否太旧(> 10 秒则忽略)
            if (snapshotAge >= 10000) {
                console.log('[git-ai] Snapshot too old, skipping AI checkpoint');
                return;
              }

            // === 关键设计 5: 从 URI Query 解析 Session ID ===
            try {
                const params = JSON.parse(snapshotInfo.uri.query);
                let sessionId = params.chatSessionId || params.sessionId;

                // VSCode 可能把 sessionId 编码在 chatSessionResource.path 中
                if (!sessionId && params.chatSessionResource?.path) {
                  sessionId = Buffer.from(
                    params.chatSessionResource.path.slice(1),
                    'base64'
                  ).toString('utf-8');
                }

                if (sessionId) {
                    // 读取会话历史文件 (VSCode 自动保存的 .jsonl)
                    const chatSessionPath = path.join(
                        storagePath,
                        'chatSessions',
                        `${sessionId}.jsonl`
                      );

                    // 调用 git-ai checkpoint
                    this.checkpoint("ai", JSON.stringify({
                        hook_event_name: "after_edit",
                        chat_session_path: chatSessionPath,  // 包含完整的 prompt 历史
                        session_id: sessionId,
                        edited_filepaths: [filePath],
                        workspace_folder: workspaceFolder.uri.fsPath,
                        dirty_files: this.getDirtyFiles(),  // 所有未保存的文件内容
                      }));
                }
              } catch (e) {
                console.error('[git-ai] Failed to parse snapshot URI', e);
              }
        }

        // 清理
        this.snapshotOpenEvents.delete(filePath);
      }
}

解读

  1. 插件式架构的可扩展性
    • 每个 AI 工具对应一个 AgentInstaller trait 实现。
    • 新增工具支持只需实现 check_hooks()install_hooks() 两个方法。
    • 主流程完全不用改,完美体现了“开闭原则”,这种设计在开源实战中值得借鉴。
  2. 配置文件注入的巧妙手法
    • 对于 VSCode/Cursor:修改 settings.json,添加 "git.path": "/path/to/git-ai"
    • 对于 JetBrains:修改 ~/.config/JetBrains/xxx/options/git.xml
    • 关键点:「让 IDE 以为在调用 git,实际调用的是 git-ai
  3. VSCode 插件的巧妙检测机制
    • 不是用启发式规则判断,而是利用 VSCode 内部机制!
    • 当 Copilot/Claude 生成代码时,VSCode 会先创建一个临时的「snapshot 文档」,URI scheme 是 chat-editing-snapshot-text-model
    • git-ai 插件监听 onDidOpenTextDocument 事件,捕获这些特殊的 snapshot 打开。
    • 当文件保存时,检查是否有对应的 snapshot 记录 → 如果有,说明是 AI 编辑。
    • 「零误判」:这不是启发式规则,而是利用 IDE 的内部信号,准确率极高。
  4. Session ID 的获取方式

    // 从 snapshot URI 的 query 参数解析
    const params = JSON.parse(snapshotInfo.uri.query);
    let sessionId = params.chatSessionId || params.sessionId;
    
    // VSCode 把 sessionId Base64 编码在 chatSessionResource.path 中
    if (!sessionId && params.chatSessionResource?.path) {
      sessionId = Buffer.from(
        params.chatSessionResource.path.slice(1),
        'base64'
      ).toString('utf-8');
    }

    为什么这么做?

    • 「利用 VSCode 的内部数据」:snapshot URI 的 query 包含了会话信息。
    • 「获取完整 Prompt 历史」chatSessions/${sessionId}.jsonl 是 VSCode 自动保存的会话记录,包含所有对话内容。
    • 「零配置」:不需要 AI 工具主动上报,直接从 IDE 的内部数据中“拿”出来。

启示

  • 面向扩展的设计:插件式架构让系统具备无限扩展能力。
  • 配置文件注入:很多时候不需要修改应用本身,改配置文件就能实现 Hook。
  • 利用平台内部机制:与其猜测用户行为(启发式),不如深挖平台的内部信号(snapshot URI),这种对技术文档和平台特性的深度理解是关键。
  • 数据“寄生”策略:VSCode 已经保存了会话历史(.jsonl),git-ai 直接读取,不需要重复收集。

三、总结与思考

在 AI 编程时代,代码的作者不再只是 Git commit 上的那个名字,而是每一行代码背后的智能体。git-ai 巧妙地运用 Git Notes 和 Checkpoint 机制,为这个新时代的代码溯源问题提供了一个优雅而高效的标准答案。其Rust实现也展示了如何用系统级语言构建高性能、可靠的基础开发者工具。

四、One more thing

git-ai 本身专注于记录每一行代码的来源。为了方便团队管理,例如查看多个仓库的 AI生成代码行数占比采纳率,社区还开源了一个配套的分析和查询工具。有需要的开发者可以使用。

项目地址:https://github.com/ForeverPx/ai-code-metrics

AI代码统计工具界面


附录:技术栈速览

模块 技术栈 作用
核心 CLI Rust + clap + smol Git 代理层,Hook 执行,高性能计算
差分算法 imara-diff 实现 Git 的 Myers diff 算法
Git 操作 gix-config + Git Plumbing 读写 Git Notes、blame、tree 操作
VSCode 插件 TypeScript + VSCode API 监听编辑事件,调用 checkpoint
JetBrains 插件 Kotlin + IntelliJ Platform 同上,适配 IntelliJ 系列 IDE
存储格式 JSON (Git Notes) + SQLite (Prompt DB) AI 归属日志 + Prompt 历史



上一篇:MedGemma 1.5与MedASR发布:医学AI性能再升级,应用场景深度解析
下一篇:AQS独占模式源码级解读:剖析ReentrantLock底层锁实现与JUC并发核心
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-23 10:27 , Processed in 0.833350 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表