package builtin // gitignore_tool.go - GitignoreTool:管理 .gitignore 文件的 Agent 工具. // // 基于 gitignore.go 中已有的解析器(ParseGitignore / CollectIgnorePatterns / IsIgnored), // 封装面向 Agent 的两种操作: // - action=add : 向 .gitignore 追加 pattern(幂等) // - action=check : 检查路径是否被忽略 // // 精妙之处(CLEVER): 复用同包的 gitignore.go 解析器,不重复实现匹配逻辑-- // Glob/Grep 工具和 GitignoreTool 共享同一套 .gitignore 语义,行为保持一致. import ( "bufio" "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "git.flytoex.net/yuanwei/flyto-agent/pkg/permission" "git.flytoex.net/yuanwei/flyto-agent/pkg/tools" ) // GitignoreTool 管理 .gitignore 文件. type GitignoreTool struct { cwd string // 默认工作目录(action=add 时 dir 缺省值) } // NewGitignoreTool 创建 GitignoreTool. // cwd 是 Agent 的当前工作目录,用作 dir 参数的默认值. func NewGitignoreTool(cwd string) *GitignoreTool { return &GitignoreTool{cwd: cwd} } // gitignoreInput 是 GitignoreTool 的输入参数. type gitignoreInput struct { Action string `json:"action"` Dir string `json:"dir"` Patterns []string `json:"patterns"` Path string `json:"path"` } func (t *GitignoreTool) Name() string { return "Gitignore" } func (t *GitignoreTool) Description(_ context.Context) string { return "Manages .gitignore files. " + "Use action=add to append patterns (idempotent), " + "action=check to test whether a path is ignored." } func (t *GitignoreTool) InputSchema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "action": {"type": "string", "enum": ["add", "check"], "description": "Operation: add patterns or check if a path is ignored"}, "dir": {"type": "string", "description": "Directory containing (or to contain) .gitignore. Defaults to cwd."}, "patterns": {"type": "array", "items": {"type": "string"}, "description": "Patterns to add (action=add)"}, "path": {"type": "string", "description": "Relative path to check (action=check)"} }, "required": ["action"] }`) } func (t *GitignoreTool) Metadata() tools.Metadata { return tools.Metadata{ ConcurrencySafe: false, ReadOnly: false, Destructive: false, SearchHint: "gitignore ignore pattern add check", PermissionClass: permission.PermClassGeneric, AuditOperation: "edit", } } func (t *GitignoreTool) Execute(ctx context.Context, input json.RawMessage, _ tools.ProgressFunc) (*tools.Result, error) { var p gitignoreInput if err := json.Unmarshal(input, &p); err != nil { return &tools.Result{Output: "error: invalid input: " + err.Error(), IsError: true}, nil } // cwd resolution: p.Dir from the model wins; absent, SubAgent ctx // override (worktree); absent, construction-time cwd. // cwd 解析: 模型传的 p.Dir 优先; 为空则 SubAgent ctx 覆盖 (worktree); // 再为空则回退构造期 cwd. dir := p.Dir if dir == "" { dir = tools.WorkdirFromContext(ctx) } if dir == "" { dir = t.cwd } switch p.Action { case "add": return t.add(dir, p.Patterns) case "check": return t.check(dir, p.Path) default: return &tools.Result{ Output: fmt.Sprintf("error: unknown action %q, must be add or check", p.Action), IsError: true, }, nil } } // add 向 dir/.gitignore 追加 patterns(幂等:已存在的跳过). func (t *GitignoreTool) add(dir string, patterns []string) (*tools.Result, error) { if len(patterns) == 0 { return &tools.Result{Output: "error: patterns required for action=add", IsError: true}, nil } gitignorePath := filepath.Join(dir, ".gitignore") // 读取已有内容,构建去重集合 existing := make(map[string]bool) func() { f, err := os.Open(gitignorePath) if err != nil { return } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line != "" { existing[line] = true } } _ = scanner.Err() // 非 EOF 错误静默忽略;已读取的条目仍有效 }() // 过滤出真正需要新增的 var toAdd []string for _, pat := range patterns { pat = strings.TrimSpace(pat) if pat == "" || existing[pat] { continue } toAdd = append(toAdd, pat) } if len(toAdd) == 0 { return &tools.Result{ Output: fmt.Sprintf("All %d pattern(s) already present in %s", len(patterns), gitignorePath), }, nil } // 追加到文件(不存在则创建) f, err := os.OpenFile(gitignorePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) if err != nil { return &tools.Result{Output: "error: " + err.Error(), IsError: true}, nil } defer f.Close() // 如果文件已有内容但末尾无换行,先补一个换行 if info, err := f.Stat(); err == nil && info.Size() > 0 { // 读末尾字节检查 rf, _ := os.Open(gitignorePath) if rf != nil { rf.Seek(-1, 2) buf := make([]byte, 1) rf.Read(buf) rf.Close() if buf[0] != '\n' { f.WriteString("\n") } } } for _, pat := range toAdd { fmt.Fprintln(f, pat) } return &tools.Result{ Output: fmt.Sprintf("Added %d pattern(s) to %s", len(toAdd), gitignorePath), Data: map[string]any{ "added": toAdd, "file": gitignorePath, }, }, nil } // check 检查 path 是否被 dir 目录下的 .gitignore 规则忽略. func (t *GitignoreTool) check(dir, path string) (*tools.Result, error) { if path == "" { return &tools.Result{Output: "error: path required for action=check", IsError: true}, nil } patterns := CollectIgnorePatterns(dir) // 统一 / 分隔符 relPath := filepath.ToSlash(path) // 判断是文件还是目录(尽量检测,不存在时默认非目录) isDir := false if info, err := os.Stat(filepath.Join(dir, path)); err == nil { isDir = info.IsDir() } ignored := IsIgnored(relPath, patterns, isDir) if ignored { return &tools.Result{ Output: fmt.Sprintf("ignored: %s is matched by .gitignore rules in %s", path, dir), Data: map[string]any{"ignored": true, "path": path}, }, nil } return &tools.Result{ Output: fmt.Sprintf("not ignored: %s is not matched by any .gitignore rule in %s", path, dir), Data: map[string]any{"ignored": false, "path": path}, }, nil }