From 8bb6e82351b58d01d57d7213b4f4bb2be5a40286 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 10:41:19 +0330 Subject: [PATCH 01/11] feat(pkg/tools/builtin/git/git.go): add read-only git toolset --- pkg/tools/builtin/git/git.go | 358 +++++++++++++++++++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 pkg/tools/builtin/git/git.go diff --git a/pkg/tools/builtin/git/git.go b/pkg/tools/builtin/git/git.go new file mode 100644 index 000000000..ffa2ab4fe --- /dev/null +++ b/pkg/tools/builtin/git/git.go @@ -0,0 +1,358 @@ +package git + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/storer" + + "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/tools" +) + +const ( + ToolNameGitStatus = "git_status" + ToolNameGitLog = "git_log" + ToolNameGitBranches = "git_branches" + ToolNameGitShow = "git_show" + ToolNameGitBlame = "git_blame" + + category = "git" + defaultLogLimit = 20 + maxBlameLines = 400 +) + +func CreateToolSet(runConfig *config.RuntimeConfig) (tools.ToolSet, error) { + wd := runConfig.WorkingDir + if wd == "" { + var err error + wd, err = os.Getwd() + if err != nil { + return nil, fmt.Errorf("determining working directory: %w", err) + } + } + return New(wd), nil +} + +type ToolSet struct { + dir string +} + +var ( + _ tools.ToolSet = (*ToolSet)(nil) + _ tools.Instructable = (*ToolSet)(nil) +) + +func New(dir string) *ToolSet { return &ToolSet{dir: dir} } + +func (t *ToolSet) open() (*gogit.Repository, error) { + repo, err := gogit.PlainOpenWithOptions(t.dir, &gogit.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return nil, fmt.Errorf("opening git repository at %s: %w", t.dir, err) + } + return repo, nil +} + +type StatusArgs struct{} + +type LogArgs struct { + Limit int `json:"limit,omitempty" jsonschema:"Maximum number of commits to return (default 20)"` + Path string `json:"path,omitempty" jsonschema:"Only show commits that touch this path"` +} + +type BranchesArgs struct{} + +type ShowArgs struct { + Ref string `json:"ref,omitempty" jsonschema:"Commit hash or revision to show (default HEAD)"` +} + +type BlameArgs struct { + Path string `json:"path" jsonschema:"File path to blame, relative to the repository root"` + Rev string `json:"rev,omitempty" jsonschema:"Commit or revision to blame at (default HEAD)"` +} + +func (t *ToolSet) status(_ context.Context, _ StatusArgs) (*tools.ToolCallResult, error) { + repo, err := t.open() + if err != nil { + return tools.ResultError("Error: " + err.Error()), nil + } + wt, err := repo.Worktree() + if err != nil { + return tools.ResultError("Error: reading worktree: " + err.Error()), nil + } + st, err := wt.Status() + if err != nil { + return tools.ResultError("Error: reading status: " + err.Error()), nil + } + + branch := "(detached HEAD)" + if head, err := repo.Head(); err == nil && head.Name().IsBranch() { + branch = head.Name().Short() + } + + var b strings.Builder + fmt.Fprintf(&b, "On branch %s\n", branch) + + paths := make([]string, 0, len(st)) + for p, fs := range st { + if fs.Staging == gogit.Unmodified && fs.Worktree == gogit.Unmodified { + continue + } + paths = append(paths, p) + } + if len(paths) == 0 { + b.WriteString("Working tree clean.") + return tools.ResultSuccess(b.String()), nil + } + sort.Strings(paths) + fmt.Fprintf(&b, "%d changed file(s) [XY = staged/worktree; M=modified A=added D=deleted R=renamed ?=untracked]:\n", len(paths)) + for _, p := range paths { + fs := st[p] + fmt.Fprintf(&b, " %c%c %s\n", fs.Staging, fs.Worktree, p) + } + return tools.ResultSuccess(b.String()), nil +} + +func (t *ToolSet) log(_ context.Context, args LogArgs) (*tools.ToolCallResult, error) { + repo, err := t.open() + if err != nil { + return tools.ResultError("Error: " + err.Error()), nil + } + + opts := &gogit.LogOptions{Order: gogit.LogOrderCommitterTime} + if args.Path != "" { + p := args.Path + opts.FileName = &p + } + iter, err := repo.Log(opts) + if err != nil { + return tools.ResultError("Error: reading log: " + err.Error()), nil + } + defer iter.Close() + + limit := args.Limit + if limit <= 0 { + limit = defaultLogLimit + } + + var b strings.Builder + count := 0 + err = iter.ForEach(func(c *object.Commit) error { + if count >= limit { + return storer.ErrStop + } + fmt.Fprintf(&b, "%s %s %s %s\n", + c.Hash.String()[:8], c.Author.When.Format("2006-01-02"), c.Author.Name, firstLine(c.Message)) + count++ + return nil + }) + if err != nil { + return tools.ResultError("Error: iterating log: " + err.Error()), nil + } + if count == 0 { + return tools.ResultSuccess("No commits found."), nil + } + return tools.ResultSuccess(b.String()), nil +} + +func (t *ToolSet) branches(_ context.Context, _ BranchesArgs) (*tools.ToolCallResult, error) { + repo, err := t.open() + if err != nil { + return tools.ResultError("Error: " + err.Error()), nil + } + + current := "" + if head, err := repo.Head(); err == nil && head.Name().IsBranch() { + current = head.Name().Short() + } + + iter, err := repo.Branches() + if err != nil { + return tools.ResultError("Error: reading branches: " + err.Error()), nil + } + defer iter.Close() + + var names []string + err = iter.ForEach(func(ref *plumbing.Reference) error { + names = append(names, ref.Name().Short()) + return nil + }) + if err != nil { + return tools.ResultError("Error: iterating branches: " + err.Error()), nil + } + if len(names) == 0 { + return tools.ResultSuccess("No branches."), nil + } + sort.Strings(names) + + var b strings.Builder + for _, n := range names { + marker := " " + if n == current { + marker = "* " + } + fmt.Fprintf(&b, "%s%s\n", marker, n) + } + return tools.ResultSuccess(b.String()), nil +} + +func (t *ToolSet) show(_ context.Context, args ShowArgs) (*tools.ToolCallResult, error) { + repo, err := t.open() + if err != nil { + return tools.ResultError("Error: " + err.Error()), nil + } + hash, err := resolveRev(repo, args.Ref) + if err != nil { + return tools.ResultError("Error: " + err.Error()), nil + } + commit, err := repo.CommitObject(hash) + if err != nil { + return tools.ResultError("Error: reading commit: " + err.Error()), nil + } + + var b strings.Builder + fmt.Fprintf(&b, "commit %s\n", commit.Hash.String()) + fmt.Fprintf(&b, "Author: %s <%s>\n", commit.Author.Name, commit.Author.Email) + fmt.Fprintf(&b, "Date: %s\n\n", commit.Author.When.Format("2006-01-02 15:04:05 -0700")) + fmt.Fprintf(&b, "%s\n", strings.TrimRight(commit.Message, "\n")) + + if stats, err := commit.Stats(); err == nil && len(stats) > 0 { + b.WriteString("\nChanged files:\n") + for _, s := range stats { + fmt.Fprintf(&b, " %s (+%d, -%d)\n", s.Name, s.Addition, s.Deletion) + } + } + return tools.ResultSuccess(b.String()), nil +} + +func (t *ToolSet) blame(_ context.Context, args BlameArgs) (*tools.ToolCallResult, error) { + if strings.TrimSpace(args.Path) == "" { + return tools.ResultError("Error: path is required."), nil + } + repo, err := t.open() + if err != nil { + return tools.ResultError("Error: " + err.Error()), nil + } + hash, err := resolveRev(repo, args.Rev) + if err != nil { + return tools.ResultError("Error: " + err.Error()), nil + } + commit, err := repo.CommitObject(hash) + if err != nil { + return tools.ResultError("Error: reading commit: " + err.Error()), nil + } + result, err := gogit.Blame(commit, args.Path) + if err != nil { + return tools.ResultError("Error: blaming " + args.Path + ": " + err.Error()), nil + } + + var b strings.Builder + for i, line := range result.Lines { + if i >= maxBlameLines { + fmt.Fprintf(&b, "... (%d more lines truncated)\n", len(result.Lines)-maxBlameLines) + break + } + short := line.Hash.String() + if len(short) > 8 { + short = short[:8] + } + fmt.Fprintf(&b, "%s %-20s %4d| %s\n", short, line.Author, i+1, line.Text) + } + return tools.ResultSuccess(b.String()), nil +} + +func resolveRev(repo *gogit.Repository, rev string) (plumbing.Hash, error) { + if strings.TrimSpace(rev) == "" { + rev = "HEAD" + } + h, err := repo.ResolveRevision(plumbing.Revision(rev)) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("resolving revision %q: %w", rev, err) + } + return *h, nil +} + +func firstLine(msg string) string { + msg = strings.TrimSpace(msg) + if i := strings.IndexByte(msg, '\n'); i >= 0 { + return msg[:i] + } + return msg +} + +func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { + ro := tools.ToolAnnotations{ReadOnlyHint: true} + return []tools.Tool{ + { + Name: ToolNameGitStatus, + Category: category, + Description: "Show the working tree status: current branch and changed files (staged, unstaged, untracked).", + Parameters: tools.MustSchemaFor[StatusArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.status), + Annotations: tools.ToolAnnotations{ReadOnlyHint: true, Title: "Git Status"}, + AddDescriptionParameter: true, + }, + { + Name: ToolNameGitLog, + Category: category, + Description: "Show recent commit history (hash, date, author, subject). Optionally limit the count or filter by path.", + Parameters: tools.MustSchemaFor[LogArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.log), + Annotations: tools.ToolAnnotations{ReadOnlyHint: true, Title: "Git Log"}, + AddDescriptionParameter: true, + }, + { + Name: ToolNameGitBranches, + Category: category, + Description: "List local branches, marking the current one with an asterisk.", + Parameters: tools.MustSchemaFor[BranchesArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.branches), + Annotations: ro, + AddDescriptionParameter: true, + }, + { + Name: ToolNameGitShow, + Category: category, + Description: "Show a commit's metadata, message, and changed files with added/deleted line counts (default HEAD).", + Parameters: tools.MustSchemaFor[ShowArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.show), + Annotations: tools.ToolAnnotations{ReadOnlyHint: true, Title: "Git Show"}, + AddDescriptionParameter: true, + }, + { + Name: ToolNameGitBlame, + Category: category, + Description: "Show line-by-line authorship (commit and author) for a file at a revision (default HEAD).", + Parameters: tools.MustSchemaFor[BlameArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.blame), + Annotations: tools.ToolAnnotations{ReadOnlyHint: true, Title: "Git Blame"}, + AddDescriptionParameter: true, + }, + }, nil +} + +func (t *ToolSet) Instructions() string { + return `## Git Tool + +Read-only access to the working git repository: + +- git_status — current branch and changed files. +- git_log — recent commits; supports limit and path filters. +- git_branches — local branches (current marked with *). +- git_show — a commit's metadata, message, and changed files. +- git_blame — line-by-line authorship for a file. + +These tools never modify the repository. To stage, commit, or checkout, use the +shell tool.` +} From bfd39cb493c9ed6df3b9699a7ddb884fd3c98d5e Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 10:41:43 +0330 Subject: [PATCH 02/11] test(pkg/tools/builtin/git/git_test.go): add unit tests for git toolset --- pkg/tools/builtin/git/git_test.go | 153 ++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 pkg/tools/builtin/git/git_test.go diff --git a/pkg/tools/builtin/git/git_test.go b/pkg/tools/builtin/git/git_test.go new file mode 100644 index 000000000..ffc847e14 --- /dev/null +++ b/pkg/tools/builtin/git/git_test.go @@ -0,0 +1,153 @@ +package git + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/require" +) + +var fixedTime = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + +func sig() *object.Signature { + return &object.Signature{Name: "Alice", Email: "alice@example.com", When: fixedTime} +} + +func newRepo(t *testing.T) (string, *gogit.Repository) { + t.Helper() + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + return dir, repo +} + +func addCommit(t *testing.T, repo *gogit.Repository, dir string, files map[string]string, msg string) plumbing.Hash { + t.Helper() + wt, err := repo.Worktree() + require.NoError(t, err) + for name, content := range files { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)) + _, err := wt.Add(name) + require.NoError(t, err) + } + h, err := wt.Commit(msg, &gogit.CommitOptions{Author: sig()}) + require.NoError(t, err) + return h +} + +func TestGitStatus(t *testing.T) { + t.Parallel() + dir, repo := newRepo(t) + addCommit(t, repo, dir, map[string]string{"a.txt": "hello\nworld\n"}, "initial") + + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("changed\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte("new\n"), 0o644)) + + res, err := New(dir).status(context.Background(), StatusArgs{}) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + require.Contains(t, res.Output, "a.txt") + require.Contains(t, res.Output, "b.txt") +} + +func TestGitStatusClean(t *testing.T) { + t.Parallel() + dir, repo := newRepo(t) + addCommit(t, repo, dir, map[string]string{"a.txt": "x\n"}, "initial") + + res, err := New(dir).status(context.Background(), StatusArgs{}) + require.NoError(t, err) + require.False(t, res.IsError) + require.Contains(t, res.Output, "clean") +} + +func TestGitLog(t *testing.T) { + t.Parallel() + dir, repo := newRepo(t) + addCommit(t, repo, dir, map[string]string{"a.txt": "1\n"}, "first commit") + addCommit(t, repo, dir, map[string]string{"a.txt": "2\n"}, "second commit") + + res, err := New(dir).log(context.Background(), LogArgs{}) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + require.Contains(t, res.Output, "first commit") + require.Contains(t, res.Output, "second commit") + + res2, err := New(dir).log(context.Background(), LogArgs{Limit: 1}) + require.NoError(t, err) + require.Contains(t, res2.Output, "second commit") + require.NotContains(t, res2.Output, "first commit") +} + +func TestGitBranches(t *testing.T) { + t.Parallel() + dir, repo := newRepo(t) + addCommit(t, repo, dir, map[string]string{"a.txt": "x\n"}, "initial") + + head, err := repo.Head() + require.NoError(t, err) + current := head.Name().Short() + + res, err := New(dir).branches(context.Background(), BranchesArgs{}) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + require.Contains(t, res.Output, current) + require.Contains(t, res.Output, "*") +} + +func TestGitShow(t *testing.T) { + t.Parallel() + dir, repo := newRepo(t) + addCommit(t, repo, dir, map[string]string{"a.txt": "one\ntwo\n"}, "add a.txt") + + res, err := New(dir).show(context.Background(), ShowArgs{}) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + require.Contains(t, res.Output, "add a.txt") + require.Contains(t, res.Output, "Alice") + require.Contains(t, res.Output, "a.txt") +} + +func TestGitBlame(t *testing.T) { + t.Parallel() + dir, repo := newRepo(t) + addCommit(t, repo, dir, map[string]string{"a.txt": "line one\nline two\n"}, "initial") + + res, err := New(dir).blame(context.Background(), BlameArgs{Path: "a.txt"}) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + require.Contains(t, res.Output, "alice@example.com") + require.Contains(t, res.Output, "line one") +} + +func TestGitBlameRequiresPath(t *testing.T) { + t.Parallel() + dir, repo := newRepo(t) + addCommit(t, repo, dir, map[string]string{"a.txt": "x\n"}, "initial") + + res, err := New(dir).blame(context.Background(), BlameArgs{}) + require.NoError(t, err) + require.True(t, res.IsError) +} + +func TestGitNotARepo(t *testing.T) { + t.Parallel() + res, err := New(t.TempDir()).status(context.Background(), StatusArgs{}) + require.NoError(t, err) + require.True(t, res.IsError) +} + +func TestGitToolSetInterfaces(t *testing.T) { + t.Parallel() + ts := New(t.TempDir()) + toolz, err := ts.Tools(context.Background()) + require.NoError(t, err) + require.Len(t, toolz, 5) + require.NotEmpty(t, ts.Instructions()) +} From e9873dc60b7defdde0c635aac9874f583fe3c70e Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 10:42:10 +0330 Subject: [PATCH 03/11] feat(pkg/teamloader/toolsets/toolsets.go): register git built-in toolset --- pkg/teamloader/toolsets/toolsets.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/teamloader/toolsets/toolsets.go b/pkg/teamloader/toolsets/toolsets.go index 79258250c..34188846e 100644 --- a/pkg/teamloader/toolsets/toolsets.go +++ b/pkg/teamloader/toolsets/toolsets.go @@ -13,6 +13,7 @@ import ( "github.com/docker/docker-agent/pkg/tools/builtin/backgroundjobs" "github.com/docker/docker-agent/pkg/tools/builtin/fetch" "github.com/docker/docker-agent/pkg/tools/builtin/filesystem" + gittool "github.com/docker/docker-agent/pkg/tools/builtin/git" "github.com/docker/docker-agent/pkg/tools/builtin/lsp" "github.com/docker/docker-agent/pkg/tools/builtin/mcpcatalog" "github.com/docker/docker-agent/pkg/tools/builtin/memory" @@ -73,6 +74,9 @@ func DefaultToolsetCreators() map[string]teamloader.ToolsetCreator { "fetch": func(_ context.Context, toolset latest.Toolset, _ string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { return fetch.CreateToolSet(toolset, runConfig) }, + "git": func(_ context.Context, _ latest.Toolset, _ string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { + return gittool.CreateToolSet(runConfig) + }, "mcp": func(ctx context.Context, toolset latest.Toolset, _ string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { return mcp.CreateToolSet(ctx, toolset, runConfig) }, From f5d2b8442800618e067952c1863ae3574456990d Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 10:42:33 +0330 Subject: [PATCH 04/11] feat(pkg/teamloader/toolsets/catalog.go): add git toolset catalog entry --- pkg/teamloader/toolsets/catalog.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/teamloader/toolsets/catalog.go b/pkg/teamloader/toolsets/catalog.go index b6cc9770d..1594db974 100644 --- a/pkg/teamloader/toolsets/catalog.go +++ b/pkg/teamloader/toolsets/catalog.go @@ -15,6 +15,7 @@ var BuiltinToolsets = []BuiltinToolsetInfo{ builtinToolset("background_jobs", "background-jobs", "Run and manage long-running shell commands"), builtinToolset("fetch", "fetch", "Read content from HTTP/HTTPS URLs"), builtinToolset("filesystem", "filesystem", "Read, write, list, search, and navigate files and directories"), + builtinToolset("git", "git", "Read-only git repository inspection: status, log, branches, show, blame"), builtinToolset("lsp", "lsp", "Connect to Language Server Protocol servers for code intelligence"), builtinToolset("mcp", "mcp", "Extend agents with external tools via the Model Context Protocol"), builtinToolset("mcp_catalog", "mcp-catalog", "Discover and activate remote MCP servers from the Docker MCP Catalog"), From 4f944859abe20c0a4501783ece0da7cc7a97bc53 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 10:43:18 +0330 Subject: [PATCH 05/11] docs(docs/configuration/tools/index.md): document git built-in toolset --- docs/configuration/tools/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration/tools/index.md b/docs/configuration/tools/index.md index 57e683e44..912773067 100644 --- a/docs/configuration/tools/index.md +++ b/docs/configuration/tools/index.md @@ -18,6 +18,7 @@ Built-in tools are included with docker-agent and require no external dependenci | Type | Description | Page | | --- | --- | --- | | `filesystem` | Read, write, list, search, navigate | [Filesystem](../../tools/filesystem/index.md) | +| `git` | Read-only repository inspection (status, log, branches, show, blame) | [Git](../../tools/git/index.md) | | `shell` | Execute shell commands synchronously | [Shell](../../tools/shell/index.md) | | `background_jobs` | Run and manage long-running shell commands | [Background Jobs](../../tools/background-jobs/index.md) | | `think` | Reasoning scratchpad | [Think](../../tools/think/index.md) | From 715aa275d972aeb497759ec691f37c537d258dcd Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 10:44:25 +0330 Subject: [PATCH 06/11] docs(docs/tools/git/index.md): add git toolset documentation --- docs/tools/git/index.md | 86 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/tools/git/index.md diff --git a/docs/tools/git/index.md b/docs/tools/git/index.md new file mode 100644 index 000000000..76f4192ba --- /dev/null +++ b/docs/tools/git/index.md @@ -0,0 +1,86 @@ +--- +title: "Git Tool" +description: "Read-only inspection of the working git repository." +keywords: docker agent, ai agents, tools, toolsets, git tool +linkTitle: "Git" +weight: 130 +canonical: https://docs.docker.com/ai/docker-agent/tools/git/ +--- + +_Read-only inspection of the working git repository._ + +## Overview + +The git toolset gives an agent structured, **read-only** access to the working repository — status, history, branches, a commit's changes, and line-level authorship. It is implemented with go-git, so it needs **no `git` binary**. + +Compared with running `git` through the `shell` tool, the git toolset returns clean, structured output the model can read reliably, is **safe by construction** (no command can modify the repository), and works even when `shell` is disabled or no `git` binary is installed. + +> [!NOTE] +> The git toolset is read-only. To stage, commit, or check out, use the [`shell`](../shell/index.md) tool. + +## Configuration + +```yaml +toolsets: + - type: git +``` + +No configuration options. The repository is opened at the agent's working directory; a subdirectory still resolves to the repository root. + +## Tools + +| Tool | Description | +| --- | --- | +| `git_status` | Current branch and changed files (staged / unstaged / untracked). | +| `git_log` | Recent commits (hash, date, author, subject). | +| `git_branches` | Local branches, current one marked with `*`. | +| `git_show` | A commit's metadata, message, and changed files with +/- counts. | +| `git_blame` | Line-by-line authorship for a file. | + +### `git_log` + +| Parameter | Required | Description | +| --- | --- | --- | +| `limit` | No | Maximum number of commits to return (default 20). | +| `path` | No | Only show commits that touch this path. | + +### `git_show` + +| Parameter | Required | Description | +| --- | --- | --- | +| `ref` | No | Commit hash or revision to show (default HEAD). | + +### `git_blame` + +| Parameter | Required | Description | +| --- | --- | --- | +| `path` | Yes | File path to blame, relative to the repository root. | +| `rev` | No | Commit or revision to blame at (default HEAD). | + +## Example + +```yaml +agents: + root: + model: openai/gpt-5-mini + description: A code review assistant + instruction: | + Review the working changes: check git_status, then git_show the latest + commit, and summarize what changed. + toolsets: + - type: git + - type: filesystem +``` + +Example `git_status` output: + +```text +On branch master +1 changed file(s) [XY = staged/worktree; M=modified A=added D=deleted R=renamed ?=untracked]: + M main.go +``` + +> [!TIP] +> **When to use** +> +> Use the git toolset whenever the agent needs repository context — before editing, to review recent history, or to find who last touched a line — without exposing the writable `shell` surface. From a91b70e36a057630b970e3654e24ec7c20692de1 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Thu, 16 Jul 2026 19:49:16 +0330 Subject: [PATCH 07/11] fix(pkg/tools/builtin/git/git.go): fixing up all review feedbacks reported on new git tool --- pkg/tools/builtin/git/git.go | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/pkg/tools/builtin/git/git.go b/pkg/tools/builtin/git/git.go index ffa2ab4fe..ef61ccf1e 100644 --- a/pkg/tools/builtin/git/git.go +++ b/pkg/tools/builtin/git/git.go @@ -2,6 +2,7 @@ package git import ( "context" + "errors" "fmt" "os" "sort" @@ -25,6 +26,7 @@ const ( category = "git" defaultLogLimit = 20 + maxLogLimit = 200 maxBlameLines = 400 ) @@ -62,7 +64,7 @@ func (t *ToolSet) open() (*gogit.Repository, error) { type StatusArgs struct{} type LogArgs struct { - Limit int `json:"limit,omitempty" jsonschema:"Maximum number of commits to return (default 20)"` + Limit int `json:"limit,omitempty" jsonschema:"Maximum number of commits to return (default 20, capped at 200)"` Path string `json:"path,omitempty" jsonschema:"Only show commits that touch this path"` } @@ -91,9 +93,13 @@ func (t *ToolSet) status(_ context.Context, _ StatusArgs) (*tools.ToolCallResult return tools.ResultError("Error: reading status: " + err.Error()), nil } - branch := "(detached HEAD)" - if head, err := repo.Head(); err == nil && head.Name().IsBranch() { - branch = head.Name().Short() + branch := "(no commits yet)" + if head, err := repo.Head(); err == nil { + if head.Name().IsBranch() { + branch = head.Name().Short() + } else { + branch = "(detached HEAD)" + } } var b strings.Builder @@ -132,6 +138,9 @@ func (t *ToolSet) log(_ context.Context, args LogArgs) (*tools.ToolCallResult, e } iter, err := repo.Log(opts) if err != nil { + if errors.Is(err, plumbing.ErrReferenceNotFound) { + return tools.ResultSuccess("No commits yet."), nil + } return tools.ResultError("Error: reading log: " + err.Error()), nil } defer iter.Close() @@ -140,6 +149,9 @@ func (t *ToolSet) log(_ context.Context, args LogArgs) (*tools.ToolCallResult, e if limit <= 0 { limit = defaultLogLimit } + if limit > maxLogLimit { + limit = maxLogLimit + } var b strings.Builder count := 0 @@ -279,15 +291,11 @@ func resolveRev(repo *gogit.Repository, rev string) (plumbing.Hash, error) { } func firstLine(msg string) string { - msg = strings.TrimSpace(msg) - if i := strings.IndexByte(msg, '\n'); i >= 0 { - return msg[:i] - } - return msg + first, _, _ := strings.Cut(strings.TrimSpace(msg), "\n") + return first } func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { - ro := tools.ToolAnnotations{ReadOnlyHint: true} return []tools.Tool{ { Name: ToolNameGitStatus, @@ -316,7 +324,7 @@ func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { Parameters: tools.MustSchemaFor[BranchesArgs](), OutputSchema: tools.MustSchemaFor[string](), Handler: tools.NewHandler(t.branches), - Annotations: ro, + Annotations: tools.ToolAnnotations{ReadOnlyHint: true, Title: "Git Branches"}, AddDescriptionParameter: true, }, { From 9e86792f508efaae069d89356fa9283dea6f3d97 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Thu, 16 Jul 2026 19:49:44 +0330 Subject: [PATCH 08/11] fix(pkg/tools/builtin/git/git_test.go): fixing up the git tool tests up with latest feedbacks --- pkg/tools/builtin/git/git_test.go | 123 +++++++++++++++++++++++++++--- 1 file changed, 112 insertions(+), 11 deletions(-) diff --git a/pkg/tools/builtin/git/git_test.go b/pkg/tools/builtin/git/git_test.go index ffc847e14..a10ae7b6b 100644 --- a/pkg/tools/builtin/git/git_test.go +++ b/pkg/tools/builtin/git/git_test.go @@ -1,9 +1,10 @@ package git import ( - "context" "os" "path/filepath" + "strconv" + "strings" "testing" "time" @@ -11,6 +12,8 @@ import ( "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/config" ) var fixedTime = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) @@ -41,6 +44,104 @@ func addCommit(t *testing.T, repo *gogit.Repository, dir string, files map[strin return h } +func TestGitUnbornHead(t *testing.T) { + t.Parallel() + dir, _ := newRepo(t) + + res, err := New(dir).status(t.Context(), StatusArgs{}) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + require.Contains(t, res.Output, "no commits yet") + + logRes, err := New(dir).log(t.Context(), LogArgs{}) + require.NoError(t, err) + require.False(t, logRes.IsError, logRes.Output) + require.Contains(t, logRes.Output, "No commits yet") +} + +func TestGitLogLimitIsCapped(t *testing.T) { + t.Parallel() + dir, repo := newRepo(t) + for i := range maxLogLimit + 5 { + addCommit(t, repo, dir, map[string]string{"a.txt": strconv.Itoa(i) + "\n"}, "commit "+strconv.Itoa(i)) + } + + res, err := New(dir).log(t.Context(), LogArgs{Limit: 1000000}) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + require.Len(t, strings.Split(strings.TrimRight(res.Output, "\n"), "\n"), maxLogLimit) +} + +func TestGitLogPathFilter(t *testing.T) { + t.Parallel() + dir, repo := newRepo(t) + addCommit(t, repo, dir, map[string]string{"a.txt": "1\n"}, "touch a") + addCommit(t, repo, dir, map[string]string{"b.txt": "1\n"}, "touch b") + + res, err := New(dir).log(t.Context(), LogArgs{Path: "b.txt"}) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + require.Contains(t, res.Output, "touch b") + require.NotContains(t, res.Output, "touch a") +} + +func TestGitShowExplicitAndInvalidRef(t *testing.T) { + t.Parallel() + dir, repo := newRepo(t) + first := addCommit(t, repo, dir, map[string]string{"a.txt": "1\n"}, "first commit") + addCommit(t, repo, dir, map[string]string{"a.txt": "2\n"}, "second commit") + + res, err := New(dir).show(t.Context(), ShowArgs{Ref: first.String()}) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + require.Contains(t, res.Output, "first commit") + require.NotContains(t, res.Output, "second commit") + + bad, err := New(dir).show(t.Context(), ShowArgs{Ref: "no-such-ref"}) + require.NoError(t, err) + require.True(t, bad.IsError) +} + +func TestGitBlameAtRev(t *testing.T) { + t.Parallel() + dir, repo := newRepo(t) + first := addCommit(t, repo, dir, map[string]string{"a.txt": "original\n"}, "first") + addCommit(t, repo, dir, map[string]string{"a.txt": "rewritten\n"}, "second") + + res, err := New(dir).blame(t.Context(), BlameArgs{Path: "a.txt", Rev: first.String()}) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + require.Contains(t, res.Output, "original") + require.NotContains(t, res.Output, "rewritten") +} + +func TestGitBlameTruncates(t *testing.T) { + t.Parallel() + dir, repo := newRepo(t) + var big strings.Builder + for i := range maxBlameLines + 50 { + big.WriteString("line " + strconv.Itoa(i) + "\n") + } + addCommit(t, repo, dir, map[string]string{"big.txt": big.String()}, "add big") + + res, err := New(dir).blame(t.Context(), BlameArgs{Path: "big.txt"}) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + require.Contains(t, res.Output, "more lines truncated") +} + +func TestCreateToolSetUsesWorkingDir(t *testing.T) { + t.Parallel() + dir, repo := newRepo(t) + addCommit(t, repo, dir, map[string]string{"a.txt": "x\n"}, "initial") + + ts, err := CreateToolSet(&config.RuntimeConfig{Config: config.Config{WorkingDir: dir}}) + require.NoError(t, err) + toolz, err := ts.Tools(t.Context()) + require.NoError(t, err) + require.Len(t, toolz, 5) +} + func TestGitStatus(t *testing.T) { t.Parallel() dir, repo := newRepo(t) @@ -49,7 +150,7 @@ func TestGitStatus(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("changed\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte("new\n"), 0o644)) - res, err := New(dir).status(context.Background(), StatusArgs{}) + res, err := New(dir).status(t.Context(), StatusArgs{}) require.NoError(t, err) require.False(t, res.IsError, res.Output) require.Contains(t, res.Output, "a.txt") @@ -61,7 +162,7 @@ func TestGitStatusClean(t *testing.T) { dir, repo := newRepo(t) addCommit(t, repo, dir, map[string]string{"a.txt": "x\n"}, "initial") - res, err := New(dir).status(context.Background(), StatusArgs{}) + res, err := New(dir).status(t.Context(), StatusArgs{}) require.NoError(t, err) require.False(t, res.IsError) require.Contains(t, res.Output, "clean") @@ -73,13 +174,13 @@ func TestGitLog(t *testing.T) { addCommit(t, repo, dir, map[string]string{"a.txt": "1\n"}, "first commit") addCommit(t, repo, dir, map[string]string{"a.txt": "2\n"}, "second commit") - res, err := New(dir).log(context.Background(), LogArgs{}) + res, err := New(dir).log(t.Context(), LogArgs{}) require.NoError(t, err) require.False(t, res.IsError, res.Output) require.Contains(t, res.Output, "first commit") require.Contains(t, res.Output, "second commit") - res2, err := New(dir).log(context.Background(), LogArgs{Limit: 1}) + res2, err := New(dir).log(t.Context(), LogArgs{Limit: 1}) require.NoError(t, err) require.Contains(t, res2.Output, "second commit") require.NotContains(t, res2.Output, "first commit") @@ -94,7 +195,7 @@ func TestGitBranches(t *testing.T) { require.NoError(t, err) current := head.Name().Short() - res, err := New(dir).branches(context.Background(), BranchesArgs{}) + res, err := New(dir).branches(t.Context(), BranchesArgs{}) require.NoError(t, err) require.False(t, res.IsError, res.Output) require.Contains(t, res.Output, current) @@ -106,7 +207,7 @@ func TestGitShow(t *testing.T) { dir, repo := newRepo(t) addCommit(t, repo, dir, map[string]string{"a.txt": "one\ntwo\n"}, "add a.txt") - res, err := New(dir).show(context.Background(), ShowArgs{}) + res, err := New(dir).show(t.Context(), ShowArgs{}) require.NoError(t, err) require.False(t, res.IsError, res.Output) require.Contains(t, res.Output, "add a.txt") @@ -119,7 +220,7 @@ func TestGitBlame(t *testing.T) { dir, repo := newRepo(t) addCommit(t, repo, dir, map[string]string{"a.txt": "line one\nline two\n"}, "initial") - res, err := New(dir).blame(context.Background(), BlameArgs{Path: "a.txt"}) + res, err := New(dir).blame(t.Context(), BlameArgs{Path: "a.txt"}) require.NoError(t, err) require.False(t, res.IsError, res.Output) require.Contains(t, res.Output, "alice@example.com") @@ -131,14 +232,14 @@ func TestGitBlameRequiresPath(t *testing.T) { dir, repo := newRepo(t) addCommit(t, repo, dir, map[string]string{"a.txt": "x\n"}, "initial") - res, err := New(dir).blame(context.Background(), BlameArgs{}) + res, err := New(dir).blame(t.Context(), BlameArgs{}) require.NoError(t, err) require.True(t, res.IsError) } func TestGitNotARepo(t *testing.T) { t.Parallel() - res, err := New(t.TempDir()).status(context.Background(), StatusArgs{}) + res, err := New(t.TempDir()).status(t.Context(), StatusArgs{}) require.NoError(t, err) require.True(t, res.IsError) } @@ -146,7 +247,7 @@ func TestGitNotARepo(t *testing.T) { func TestGitToolSetInterfaces(t *testing.T) { t.Parallel() ts := New(t.TempDir()) - toolz, err := ts.Tools(context.Background()) + toolz, err := ts.Tools(t.Context()) require.NoError(t, err) require.Len(t, toolz, 5) require.NotEmpty(t, ts.Instructions()) From 3295c65422cdffaf98eda8cc340815b0f76fa015 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Thu, 16 Jul 2026 19:50:20 +0330 Subject: [PATCH 09/11] fix(docs/tools/git/index.md): updating up the docs with latest changes --- docs/tools/git/index.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/tools/git/index.md b/docs/tools/git/index.md index 76f4192ba..9568b7508 100644 --- a/docs/tools/git/index.md +++ b/docs/tools/git/index.md @@ -3,7 +3,7 @@ title: "Git Tool" description: "Read-only inspection of the working git repository." keywords: docker agent, ai agents, tools, toolsets, git tool linkTitle: "Git" -weight: 130 +weight: 125 canonical: https://docs.docker.com/ai/docker-agent/tools/git/ --- @@ -27,6 +27,20 @@ toolsets: No configuration options. The repository is opened at the agent's working directory; a subdirectory still resolves to the repository root. +> [!WARNING] +> **The repository is discovered by walking up parent directories.** If the working +> directory is not itself a repository but an ancestor is (for example a +> home directory tracked as dotfiles), the toolset resolves to that ancestor and +> `git_show` / `git_blame` can expose its full history and file contents. The +> filesystem toolset's allow/deny lists do **not** apply here. Only enable this +> toolset where the surrounding repository is safe to read. + +> [!NOTE] +> **Performance.** go-git is pure Go, which costs speed on large repositories: +> `git_status` rehashes the whole worktree, and `git_blame` scales with history +> depth times file size — its 400-line output cap is applied *after* the full +> computation, so it does not make blaming a large file cheaper. + ## Tools | Tool | Description | From 106828794183ecdeabbceddb880c55493f4296b8 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Thu, 16 Jul 2026 19:51:58 +0330 Subject: [PATCH 10/11] fix(agent-schema.json): adding up the git tool into toolsets --- agent-schema.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/agent-schema.json b/agent-schema.json index 731c133a5..2170ce61a 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -1988,7 +1988,8 @@ "open_url", "model_picker", "background_agents", - "rag" + "rag", + "git" ] }, "instruction": { @@ -2323,7 +2324,8 @@ "lsp", "user_prompt", "model_picker", - "background_agents" + "background_agents", + "git" ] } } From 085e39f71bd1f31e558ec3f8356ccd99f758e89b Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Thu, 16 Jul 2026 20:08:16 +0330 Subject: [PATCH 11/11] fix(agent-schema.json): fixing the malformed agent-schema.json causing from merge --- agent-schema.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/agent-schema.json b/agent-schema.json index c3c1c1ece..35ad9a393 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -2357,8 +2357,7 @@ "user_prompt", "model_picker", "background_agents", - "scheduler" - "background_agents", + "scheduler", "git" ] }