diff --git a/docs/RFC-GO-AGENT-COMMANDS.md b/docs/RFC-GO-AGENT-COMMANDS.md index 9f4f422..6b19fc9 100644 --- a/docs/RFC-GO-AGENT-COMMANDS.md +++ b/docs/RFC-GO-AGENT-COMMANDS.md @@ -31,6 +31,7 @@ core-agent [command] | `pr/merge` | Merge Forge PR | | `repo/get` | Get Forge repo info | | `repo/list` | List Forge repos | +| `repo/sync` | Fetch and optionally reset a local repo from origin | | `mcp` | Start MCP server (stdio) | | `serve` | Start HTTP/API server | diff --git a/pkg/agentic/commands_forge.go b/pkg/agentic/commands_forge.go index 67fdfe1..f3296a0 100644 --- a/pkg/agentic/commands_forge.go +++ b/pkg/agentic/commands_forge.go @@ -106,6 +106,7 @@ func (s *PrepSubsystem) registerForgeCommands() { c.Command("pr/close", core.Command{Description: "Close a Forge PR", Action: s.cmdPRClose}) c.Command("repo/get", core.Command{Description: "Get Forge repo info", Action: s.cmdRepoGet}) c.Command("repo/list", core.Command{Description: "List Forge repos for an org", Action: s.cmdRepoList}) + c.Command("repo/sync", core.Command{Description: "Fetch and optionally reset a local repo from origin", Action: s.cmdRepoSync}) } func (s *PrepSubsystem) cmdIssueGet(options core.Options) core.Result { @@ -357,3 +358,106 @@ func (s *PrepSubsystem) cmdRepoList(options core.Options) core.Result { core.Print(nil, "\n %d repos", len(repos)) return core.Result{OK: true} } + +// result := c.Command("repo/sync").Run(ctx, core.NewOptions( +// +// core.Option{Key: "_arg", Value: "go-io"}, +// core.Option{Key: "reset", Value: true}, +// +// )) +func (s *PrepSubsystem) cmdRepoSync(options core.Options) core.Result { + ctx := context.Background() + org, repo, _ := parseForgeArgs(options) + if repo == "" { + core.Print(nil, "usage: core-agent repo sync [--org=core] [--branch=main] [--reset]") + return core.Result{Value: core.E("agentic.cmdRepoSync", "repo is required", nil), OK: false} + } + + branch := options.String("branch") + reset := options.Bool("reset") + repoDir := s.localRepoDir(org, repo) + if repoDir == "" { + return core.Result{Value: core.E("agentic.cmdRepoSync", "local repo directory is unavailable", nil), OK: false} + } + if !fs.Exists(repoDir) || fs.IsFile(repoDir) { + core.Print(nil, "repo not found: %s", repoDir) + return core.Result{Value: core.E("agentic.cmdRepoSync", "local repo not found", nil), OK: false} + } + + if branch == "" { + branch = s.currentBranch(repoDir) + } + if branch == "" { + branch = s.DefaultBranch(repoDir) + } + if branch == "" { + return core.Result{Value: core.E("agentic.cmdRepoSync", "branch is required", nil), OK: false} + } + + process := s.Core().Process() + fetchResult := process.RunIn(ctx, repoDir, "git", "fetch", "origin") + if !fetchResult.OK { + core.Print(nil, "error: %v", fetchResult.Value) + return core.Result{Value: fetchResult.Value, OK: false} + } + + core.Print(nil, "fetched %s/%s@%s", org, repo, branch) + if reset { + resetResult := process.RunIn(ctx, repoDir, "git", "reset", "--hard", core.Concat("origin/", branch)) + if !resetResult.OK { + core.Print(nil, "error: %v", resetResult.Value) + return core.Result{Value: resetResult.Value, OK: false} + } + core.Print(nil, "reset %s to origin/%s", repoDir, branch) + } + + return core.Result{OK: true} +} + +// repoDir := s.localRepoDir("core", "go-io") +func (s *PrepSubsystem) localRepoDir(org, repo string) string { + basePath := s.codePath + if basePath == "" { + basePath = core.Env("CODE_PATH") + } + if basePath == "" { + basePath = core.JoinPath(HomeDir(), "Code") + } + + normalisedRepo := core.Replace(repo, "\\", "/") + repoName := core.PathBase(normalisedRepo) + orgName := core.PathBase(core.Replace(org, "\\", "/")) + if orgName == "" { + parts := core.Split(normalisedRepo, "/") + if len(parts) > 1 { + orgName = parts[0] + } + } + + candidates := []string{} + if orgName != "" { + candidates = append(candidates, core.JoinPath(basePath, orgName, repoName)) + } + candidates = append(candidates, core.JoinPath(basePath, repoName)) + + for _, candidate := range candidates { + if fs.Exists(candidate) && !fs.IsFile(candidate) { + return candidate + } + } + + if len(candidates) == 0 { + return "" + } + return candidates[0] +} + +// branch := s.currentBranch("/srv/Code/core/go-io") +func (s *PrepSubsystem) currentBranch(repoDir string) string { + ctx := context.Background() + result := s.Core().Process().RunIn(ctx, repoDir, "git", "rev-parse", "--abbrev-ref", "HEAD") + if !result.OK { + return "" + } + return core.Trim(result.Value.(string)) +} diff --git a/pkg/agentic/commands_forge_test.go b/pkg/agentic/commands_forge_test.go index 4e85f9c..2480093 100644 --- a/pkg/agentic/commands_forge_test.go +++ b/pkg/agentic/commands_forge_test.go @@ -3,6 +3,7 @@ package agentic import ( + "context" "net/http" "net/http/httptest" "testing" @@ -193,3 +194,54 @@ func TestCommandsforge_CmdRepoList_Ugly(t *testing.T) { r := s.cmdRepoList(core.NewOptions(core.Option{Key: "org", Value: ""})) assert.False(t, r.OK) } + +func TestCommandsforge_CmdRepoSync_Bad_MissingRepo(t *testing.T) { + s, _ := testPrepWithCore(t, nil) + r := s.cmdRepoSync(core.NewOptions()) + assert.False(t, r.OK) +} + +func TestCommandsforge_CmdRepoSync_Good_ResetLocalRepo(t *testing.T) { + codeDir := t.TempDir() + orgDir := core.JoinPath(codeDir, "core") + fs.EnsureDir(orgDir) + repoDir := core.JoinPath(orgDir, "test-repo") + fs.EnsureDir(repoDir) + + binDir := t.TempDir() + logPath := core.JoinPath(t.TempDir(), "git.log") + gitPath := core.JoinPath(binDir, "git") + fs.Write(gitPath, core.Concat("#!/bin/sh\nprintf '%s\\n' \"$*\" >> ", logPath, "\nexit 0\n")) + assert.True(t, testCore.Process().RunIn(context.Background(), binDir, "chmod", "+x", gitPath).OK) + oldPath := core.Env("PATH") + t.Setenv("PATH", core.Concat(binDir, ":", oldPath)) + + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + codePath: codeDir, + } + + output := captureStdout(t, func() { + r := s.cmdRepoSync(core.NewOptions( + core.Option{Key: "_arg", Value: "test-repo"}, + core.Option{Key: "org", Value: "core"}, + core.Option{Key: "branch", Value: "main"}, + core.Option{Key: "reset", Value: true}, + )) + assert.True(t, r.OK) + }) + + assert.Contains(t, output, "fetched core/test-repo@main") + assert.Contains(t, output, "reset") + + logResult := fs.Read(logPath) + assert.True(t, logResult.OK) + assert.Contains(t, logResult.Value.(string), "fetch origin") + assert.Contains(t, logResult.Value.(string), "reset --hard origin/main") +} + +func TestCommandsforge_RegisterForgeCommands_Good_RepoSyncRegistered(t *testing.T) { + s, c := testPrepWithCore(t, nil) + s.registerForgeCommands() + assert.Contains(t, c.Commands(), "repo/sync") +}