diff --git a/agent-schema.json b/agent-schema.json index 41468443c..35ad9a393 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -2020,7 +2020,8 @@ "model_picker", "background_agents", "scheduler", - "rag" + "rag", + "git" ] }, "instruction": { @@ -2356,7 +2357,8 @@ "user_prompt", "model_picker", "background_agents", - "scheduler" + "scheduler", + "git" ] } } diff --git a/docs/configuration/tools/index.md b/docs/configuration/tools/index.md index c84f65b0b..e9ada86ab 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) | | `scheduler` | Schedule instructions to run at a time or on a recurring interval | [Scheduler](../../tools/scheduler/index.md) | diff --git a/docs/tools/git/index.md b/docs/tools/git/index.md new file mode 100644 index 000000000..9568b7508 --- /dev/null +++ b/docs/tools/git/index.md @@ -0,0 +1,100 @@ +--- +title: "Git Tool" +description: "Read-only inspection of the working git repository." +keywords: docker agent, ai agents, tools, toolsets, git tool +linkTitle: "Git" +weight: 125 +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. + +> [!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 | +| --- | --- | +| `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. diff --git a/pkg/teamloader/toolsets/catalog.go b/pkg/teamloader/toolsets/catalog.go index 7f0e2db41..223d14ef6 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"), diff --git a/pkg/teamloader/toolsets/toolsets.go b/pkg/teamloader/toolsets/toolsets.go index 8bb7e6333..08b1dc2e5 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" @@ -77,6 +78,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) }, diff --git a/pkg/tools/builtin/git/git.go b/pkg/tools/builtin/git/git.go new file mode 100644 index 000000000..ef61ccf1e --- /dev/null +++ b/pkg/tools/builtin/git/git.go @@ -0,0 +1,366 @@ +package git + +import ( + "context" + "errors" + "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 + maxLogLimit = 200 + 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, capped at 200)"` + 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 := "(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 + 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 { + 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() + + limit := args.Limit + if limit <= 0 { + limit = defaultLogLimit + } + if limit > maxLogLimit { + limit = maxLogLimit + } + + 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 { + first, _, _ := strings.Cut(strings.TrimSpace(msg), "\n") + return first +} + +func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { + 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: tools.ToolAnnotations{ReadOnlyHint: true, Title: "Git Branches"}, + 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.` +} diff --git a/pkg/tools/builtin/git/git_test.go b/pkg/tools/builtin/git/git_test.go new file mode 100644 index 000000000..a10ae7b6b --- /dev/null +++ b/pkg/tools/builtin/git/git_test.go @@ -0,0 +1,254 @@ +package git + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "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" + + "github.com/docker/docker-agent/pkg/config" +) + +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 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) + 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(t.Context(), 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(t.Context(), 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(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(t.Context(), 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(t.Context(), 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(t.Context(), 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(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") + 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(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(t.Context(), 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(t.Context()) + require.NoError(t, err) + require.Len(t, toolz, 5) + require.NotEmpty(t, ts.Instructions()) +}