feat(agentic): add repo sync command

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 11:33:37 +00:00
parent b3bb77570c
commit f5dff3d822
3 changed files with 157 additions and 0 deletions

View file

@ -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 |

View file

@ -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 <repo> [--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))
}

View file

@ -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: "<script>alert(1)</script>"}))
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")
}