package memory // sync_git.go - 基于 Git 的记忆同步适配器(模块 10.2). // // # 定位 // // GitSyncAdapter 是 SyncAdapter 的 Git 后端实现,适用于: // - CLI 模式:开发者团队通过共享 git repo 同步项目记忆 // - 本地备份:个人记忆推送到私有 git 仓库(GitHub/GitLab/Gitea/自建) // - 离线优先:git 操作在网络不可达时可以暂缓(push 失败不影响本地写入) // // # 两种模式 // // **StandaloneMode(默认)**: 记忆目录本身是一个独立的 git repo. // // 适合个人备份或团队专用 memory 仓库,不污染项目主仓库历史. // 目录结构: // ~/.flyto/projects//memory/ ← 这是一个 .git repo // .git/ // MEMORY.md // user_profile.md // project_context.md // // **EmbeddedMode**: 记忆目录在项目 git repo 内的子目录,使用专属分支隔离. // // 适合不想维护独立 memory repo,希望 memory 与项目代码同库的场景. // 精妙之处(CLEVER): 使用专属分支 "flyto/memory"(可配置)隔离 memory commits, // 主分支历史不受污染.团队成员 checkout 主分支时看不到 memory 文件, // 只有 flyto/memory 分支才有. // 目录结构(在项目 repo 内): // 项目根/ // .git/ ← 项目 repo // src/ // .flyto/ // memory/ ← EmbeddedMode 的 localDir // // # 升华改进(ELEVATED) // // 早期实现用自定义 HTTP delta 协议同步, // 实现了 ETag,If-Match,412 冲突重试,批量分包等逻辑,共 1256 行. // 这些复杂性本质上是在重新实现 git 的核心能力. // // 我们用 git 直接替代: // - 增量同步 ← git fetch + diff(比 MD5 delta 更精准,支持部分文件变更) // - 冲突检测 ← git diverge check(比 ETag 更健壮,支持离线期间多次提交) // - 冲突解决 ← git rebase / merge / reset(三路合并优于"本地永远胜") // - 历史审计 ← git log(完整的 who/when/what,比单纯文件 mtime 更可靠) // // 替代方案: - // 否决原因:需要专用服务端;离线不可用;实现复杂度高;不支持三路合并. // // 替代方案:<直接用 go-git 库,不依赖外部 git 命令> - // 否决原因:go-git 是外部依赖,违反零外部依赖原则; // 且 go-git 对 rebase 的支持不完整,需要降级到 fetch+reset. // // # 安全说明 // // GitSyncAdapter 通过 exec.Command 调用系统 git.命令参数均为静态字符串或 // 经过 filepath.Clean 处理的路径,不含用户可控字符串,无命令注入风险. // 凭证通过 git 自身的凭证机制(SSH key / .netrc / credential helper)管理, // GitSyncAdapter 不存储或传输任何凭证. import ( "bytes" "context" "fmt" "os" "os/exec" "path/filepath" "strings" gitlib "git.flytoex.net/yuanwei/flyto-agent/internal/syslib/git" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) // GitMode 定义 GitSyncAdapter 的工作模式. type GitMode int const ( // GitModeStandalone 独立仓库模式:localDir 本身是一个 git repo. // Pull = git pull --rebase --autostash // Push = git add -A && git commit && git push GitModeStandalone GitMode = iota // GitModeEmbedded 嵌入模式:localDir 在项目 git repo 内,使用专属分支. // Pull = git fetch origin && git checkout flyto/memory && git rebase origin/flyto/memory // Push = git add && git commit && git push origin flyto/memory GitModeEmbedded ) // GitSyncOptions 是 GitSyncAdapter 的构造选项. type GitSyncOptions struct { // Mode 工作模式,默认 GitModeStandalone. Mode GitMode // Remote git remote 名称,默认 "origin". Remote string // Branch 分支名,默认: // - Standalone: "main" // - Embedded: "flyto/memory" Branch string // CommitAuthorName git commit 的作者名,默认 "Flyto Agent". CommitAuthorName string // CommitAuthorEmail git commit 的作者邮箱,默认 "agent@flyto.local". CommitAuthorEmail string // GitBin git 二进制路径,默认在 PATH 中查找 "git". // 用于测试时注入 mock git 路径. GitBin string // Executor 是子进程启动抽象 (M1 方案 β 严格 DI). 必填, 零值会在 // NewGitSyncAdapter 里 panic. 本地 CLI 传 execenv.DefaultExecutor{}, // 云端 SaaS 由 platform 层传 sandbox.Backend (ClassMemoryGit 映射到 // system pod 或 tenant VM 路由决策发生在 backend 内). Executor execenv.Executor } // GitSyncAdapter 是基于 git 命令的 SyncAdapter 实现. // // 零值不可用,必须通过 NewGitSyncAdapter 构造. type GitSyncAdapter struct { mode GitMode remote string branch string commitAuthorName string commitAuthorEmail string gitBin string executor execenv.Executor } // NewGitSyncAdapter 创建 GitSyncAdapter. // // localDir 是记忆目录路径(不是 git repo 根目录). // 如果 gitBin 为空,自动在 PATH 中查找 git. // // opts.Executor 必填, nil 会 panic. 严格 DI 契约 (M1 方案 β). func NewGitSyncAdapter(opts GitSyncOptions) *GitSyncAdapter { if opts.Executor == nil { panic("memory.NewGitSyncAdapter: opts.Executor is required (M1 strict DI, no fallback)") } // 设置默认值 remote := opts.Remote if remote == "" { remote = "origin" } branch := opts.Branch if branch == "" { if opts.Mode == GitModeEmbedded { branch = "flyto/memory" } else { branch = "main" } } authorName := opts.CommitAuthorName if authorName == "" { authorName = "Flyto Agent" } authorEmail := opts.CommitAuthorEmail if authorEmail == "" { authorEmail = "agent@flyto.local" } gitBin := opts.GitBin if gitBin == "" { gitBin = "git" } // Remote and Branch flow into argv for fetch/push/rebase/reset/pull/init/log // (11 call sites). Without an up-front check, a remote starting with "-" or // a branch containing ".." is a flag-injection / path-traversal vector into // git's own option and ref parsers. Validate once at construction to fail // fast; downstream call sites can treat the fields as trusted thereafter. // // Remote 和 Branch 会进入 fetch / push / rebase / reset / pull / init / // log 等 11 处 argv. 没有上游校验时, remote 以 "-" 开头或 branch 含 ".." // 就是 flag-injection / 路径遍历对 git 自身选项 / ref 解析器的攻击面. // 构造期一次校验, 失败即炸 (panic 风格对齐上面的 nil Executor 约束); // 下游调用点后续可将二者视为可信. if err := gitlib.ValidateRef(remote); err != nil { panic("memory.NewGitSyncAdapter: invalid remote: " + err.Error()) } if err := gitlib.ValidateRef(branch); err != nil { panic("memory.NewGitSyncAdapter: invalid branch: " + err.Error()) } return &GitSyncAdapter{ mode: opts.Mode, remote: remote, branch: branch, commitAuthorName: authorName, commitAuthorEmail: authorEmail, gitBin: gitBin, executor: opts.Executor, } } // IsAvailable 检查 git 二进制是否存在,且 localDir 在 git repo 内. // // 注意:IsAvailable 本身不执行网络操作,只检查本地条件. // 远端不可达时 IsAvailable 仍返回 true,Pull/Push 时才发现网络错误. func (g *GitSyncAdapter) IsAvailable() bool { // 1. git 二进制必须存在 if _, err := exec.LookPath(g.gitBin); err != nil { return false } return true } // isGitRepo 检查指定目录是否在 git repo 内(或本身是 git repo). // 通过 "git rev-parse --git-dir" 判断,失败说明不在任何 git repo 内. func (g *GitSyncAdapter) isGitRepo(ctx context.Context, dir string) bool { proc := g.executor.Command(ctx, execenv.Spec{ Class: execenv.ClassMemoryGit, Path: g.gitBin, Args: []string{"-C", dir, "rev-parse", "--git-dir"}, Env: execenv.FullInheritMap(nil), }) return proc.Run() == nil } // Pull 从远端拉取最新状态. // // 根据 ConflictPolicy 已在 Push 时处理,Pull 统一使用 rebase 策略: // - 保留本地 commit,将远端 commit 应用到本地之前 // - --autostash 自动 stash 未提交的工作区变更 // // 如果 localDir 不在 git repo 内,尝试 git init + git remote add. func (g *GitSyncAdapter) Pull(ctx context.Context, localDir string) (int, error) { dir := filepath.Clean(localDir) // 确保目录存在 if err := os.MkdirAll(dir, 0755); err != nil { return 0, fmt.Errorf("git sync: create dir: %w", err) } // 如果不在 git repo 内,直接返回(不自动 init,避免意外创建 repo) if !g.isGitRepo(ctx, dir) { // 精妙之处(CLEVER): 不自动 init 是一个有意的安全决策-- // 如果调用方传入了一个错误的 dir(如根目录),自动 init 会在根目录创建 .git, // 污染整个文件系统结构.让调用方显式 init 或使用 InitRepo 方法. return 0, fmt.Errorf("git sync: %s is not in a git repository (run git init first)", dir) } // 先 fetch,再 rebase(fetch 失败时本地状态不变,比 pull 更安全) if err := g.runGit(ctx, dir, "fetch", g.remote); err != nil { return 0, fmt.Errorf("git sync: fetch: %w", err) } // 统计 fetch 带来了多少新文件(Pull 前后的 diff) before, _ := g.listTrackedFiles(ctx, dir) // rebase:将远端 commit 置于本地 commit 之前,--autostash 处理未提交变更 remoteRef := g.remote + "/" + g.branch if err := g.runGit(ctx, dir, "rebase", "--autostash", remoteRef); err != nil { // rebase 失败可能是冲突,尝试 abort 避免 repo 进入 rebase 状态 _ = g.runGit(ctx, dir, "rebase", "--abort") return 0, fmt.Errorf("git sync: rebase: %w", err) } after, _ := g.listTrackedFiles(ctx, dir) pulled := countNewFiles(before, after) return pulled, nil } // Push 将本地变化提交并推送到远端. // // 流程:git add -A → git commit → git push // 如果没有可提交的变化(working tree clean),跳过 commit+push,返回 (0, nil). // // ConflictPolicy 语义: // - ConflictLocalWins:push --force-with-lease(本地 commit 优先,但检查远端是否意外进展) // - ConflictServerWins:先 Pull(reset --hard),再 push(此时无冲突) // - ConflictMerge:git pull --no-rebase(三路合并),有冲突时提交冲突标记文件 // - ConflictFail:检测到本地与远端 diverge 时直接返回 ErrSyncConflict func (g *GitSyncAdapter) Push(ctx context.Context, localDir string, policy ConflictPolicy) (int, error) { dir := filepath.Clean(localDir) if !g.isGitRepo(ctx, dir) { return 0, fmt.Errorf("git sync: %s is not in a git repository", dir) } // 根据冲突策略决定 push 前的动作 switch policy { case ConflictServerWins: // 服务器优先:先 reset 到远端(丢弃本地未 push 的 commit),再 push remoteRef := g.remote + "/" + g.branch if err := g.runGit(ctx, dir, "fetch", g.remote); err != nil { return 0, fmt.Errorf("git sync: fetch (server-wins): %w", err) } if err := g.runGit(ctx, dir, "reset", "--hard", remoteRef); err != nil { return 0, fmt.Errorf("git sync: reset (server-wins): %w", err) } case ConflictMerge: // 三路合并:git pull --no-rebase(merge commit) if err := g.runGit(ctx, dir, "pull", "--no-rebase", "--no-edit", g.remote, g.branch); err != nil { // merge 失败时仍继续--冲突标记文件会被 add + commit // AI 可以在下次读取时感知并解决冲突标记 } case ConflictFail: // 检查本地是否 diverged(有本地 commit 不在远端) if diverged, err := g.isDiverged(ctx, dir); err != nil { return 0, err } else if diverged { return 0, fmt.Errorf("git sync: %w (use a different ConflictPolicy to resolve)", ErrSyncConflict) } case ConflictLocalWins: // 本地优先:不做任何预处理,push 时用 --force-with-lease // --force-with-lease 比 --force 更安全:如果远端在上次 fetch 后有新进展, // 它会拒绝 push(防止意外覆盖 teammate 的工作). // 真正的"本地强制覆盖"场景应该由用户手动处理. } // 统计将提交多少文件 changed, _ := g.countChangedFiles(ctx, dir) if changed == 0 { // 工作区干净,无需提交 return 0, nil } // git add -A if err := g.runGit(ctx, dir, "add", "-A"); err != nil { return 0, fmt.Errorf("git sync: add: %w", err) } // git commit commitMsg := "flyto: memory sync" env := map[string]string{ "GIT_AUTHOR_NAME": g.commitAuthorName, "GIT_AUTHOR_EMAIL": g.commitAuthorEmail, "GIT_COMMITTER_NAME": g.commitAuthorName, "GIT_COMMITTER_EMAIL": g.commitAuthorEmail, } if err := g.runGitWithEnv(ctx, dir, env, "commit", "-m", commitMsg); err != nil { // "nothing to commit" 不是错误 if strings.Contains(err.Error(), "nothing to commit") || strings.Contains(err.Error(), "nothing added to commit") { return 0, nil } return 0, fmt.Errorf("git sync: commit: %w", err) } // git push pushArgs := []string{"push", g.remote, g.branch} if policy == ConflictLocalWins { pushArgs = []string{"push", "--force-with-lease", g.remote, g.branch} } if err := g.runGit(ctx, dir, pushArgs...); err != nil { return 0, fmt.Errorf("git sync: push: %w", err) } return changed, nil } // InitRepo 在 localDir 初始化 git repo 并设置 remote. // // 用于首次使用 GitSyncAdapter 时的一次性初始化. // 如果已是 git repo,只确保 remote 存在. func (g *GitSyncAdapter) InitRepo(ctx context.Context, localDir string, remoteURL string) error { dir := filepath.Clean(localDir) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("git sync: init: create dir: %w", err) } if !g.isGitRepo(ctx, dir) { if err := g.runGit(ctx, dir, "init", "-b", g.branch); err != nil { return fmt.Errorf("git sync: init: %w", err) } } // 检查 remote 是否已存在 out, _ := g.runGitOutput(ctx, dir, "remote", "get-url", g.remote) if strings.TrimSpace(out) == "" { // remote 不存在,添加 if err := g.runGit(ctx, dir, "remote", "add", g.remote, remoteURL); err != nil { return fmt.Errorf("git sync: add remote: %w", err) } } return nil } // ───────────────────────────────────────────────────────────────────────────── // 内部辅助方法 // ───────────────────────────────────────────────────────────────────────────── // runGit 执行 git 子命令,stdout/stderr 合并到错误信息. func (g *GitSyncAdapter) runGit(ctx context.Context, dir string, args ...string) error { _, err := g.runGitOutputErr(ctx, dir, nil, args...) return err } // runGitWithEnv 执行 git 子命令,带额外环境变量. func (g *GitSyncAdapter) runGitWithEnv(ctx context.Context, dir string, extraEnv map[string]string, args ...string) error { _, err := g.runGitOutputErr(ctx, dir, extraEnv, args...) return err } // runGitOutput 执行 git 子命令,返回 stdout 内容. func (g *GitSyncAdapter) runGitOutput(ctx context.Context, dir string, args ...string) (string, error) { return g.runGitOutputErr(ctx, dir, nil, args...) } // runGitOutputErr 是所有 runGit* 的底层实现. // // Env 走 FullInheritMap 全继承 (M1 4d+): 保留 SSH_AUTH_SOCK / HOME / // credential helper 等 git push 必需的变量, extraEnv 覆盖同名 (主要是 // GIT_AUTHOR_* / GIT_COMMITTER_*). ClassMemoryGit 告诉 backend "这是引擎 // 自用的 memory 同步, 不是租户代码", 云端 backend 据此路由到 system pod 或 // tenant VM, core 层不做行为差异. func (g *GitSyncAdapter) runGitOutputErr(ctx context.Context, dir string, extraEnv map[string]string, args ...string) (string, error) { var stdout, stderr bytes.Buffer proc := g.executor.Command(ctx, execenv.Spec{ Class: execenv.ClassMemoryGit, Path: g.gitBin, Args: args, Env: execenv.FullInheritMap(extraEnv), Stdout: &stdout, Stderr: &stderr, WorkDir: dir, }) if err := proc.Run(); err != nil { errMsg := strings.TrimSpace(stderr.String()) if errMsg == "" { errMsg = strings.TrimSpace(stdout.String()) } return "", fmt.Errorf("%w: %s", err, errMsg) } return stdout.String(), nil } // listTrackedFiles 返回当前 HEAD 中被追踪的文件集合(用于计算 pull 带来的新文件数). func (g *GitSyncAdapter) listTrackedFiles(ctx context.Context, dir string) (map[string]struct{}, error) { out, err := g.runGitOutput(ctx, dir, "ls-files") if err != nil { return nil, err } files := map[string]struct{}{} for _, line := range strings.Split(strings.TrimSpace(out), "\n") { if line != "" { files[line] = struct{}{} } } return files, nil } // countNewFiles 统计 after 中有多少文件不在 before 中. func countNewFiles(before, after map[string]struct{}) int { count := 0 for f := range after { if _, exists := before[f]; !exists { count++ } } return count } // countChangedFiles 统计工作区中有变化的文件数(staged + unstaged + untracked). func (g *GitSyncAdapter) countChangedFiles(ctx context.Context, dir string) (int, error) { // git status --porcelain 每行一个变化的文件 out, err := g.runGitOutput(ctx, dir, "status", "--porcelain") if err != nil { return 0, err } count := 0 for _, line := range strings.Split(strings.TrimSpace(out), "\n") { if strings.TrimSpace(line) != "" { count++ } } return count, nil } // isDiverged 检查本地 branch 是否与 remote/branch diverged(有本地独有的 commit). // // 用于 ConflictFail 策略的冲突检测. // 精妙之处(CLEVER): 使用 git log remote..HEAD 计算本地独有 commit 数-- // 如果结果非空,说明本地已 diverge,push 会产生冲突. // 比 fetch + 比较 checksum 更准确(git 理解历史,checksum 只看快照). func (g *GitSyncAdapter) isDiverged(ctx context.Context, dir string) (bool, error) { if err := g.runGit(ctx, dir, "fetch", g.remote); err != nil { return false, fmt.Errorf("git sync: fetch (conflict check): %w", err) } remoteRef := g.remote + "/" + g.branch out, err := g.runGitOutput(ctx, dir, "log", "--oneline", remoteRef+"..HEAD") if err != nil { // 可能是 remote ref 不存在(首次 push),不算 diverge return false, nil } return strings.TrimSpace(out) != "", nil }