feat: add git operations
This commit is contained in:
108
internal/gitops/git.go
Normal file
108
internal/gitops/git.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package gitops
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Credentials struct {
|
||||||
|
Username string
|
||||||
|
Secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Runner struct{}
|
||||||
|
|
||||||
|
func New() *Runner {
|
||||||
|
return &Runner{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) CheckAvailable() error {
|
||||||
|
_, err := exec.LookPath("git")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) Clone(ctx context.Context, remoteURL, dest string, creds Credentials) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.run(ctx, "", creds, "clone", remoteURL, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) Pull(ctx context.Context, repoPath string, creds Credentials) error {
|
||||||
|
return r.run(ctx, repoPath, creds, "pull", "--ff-only")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) Fetch(ctx context.Context, repoPath string, creds Credentials) error {
|
||||||
|
return r.run(ctx, repoPath, creds, "fetch", "origin")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) CurrentCommit(repoPath string) (string, error) {
|
||||||
|
return r.output(context.Background(), repoPath, Credentials{}, "rev-parse", "HEAD")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) RemoteCommit(ctx context.Context, repoPath, branch string, creds Credentials) (string, error) {
|
||||||
|
out, err := r.output(ctx, repoPath, creds, "ls-remote", "origin", "refs/heads/"+branch)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fields := strings.Fields(out)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return "", fmt.Errorf("remote branch %s not found", branch)
|
||||||
|
}
|
||||||
|
return fields[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) HasLocalChanges(repoPath string) (bool, error) {
|
||||||
|
out, err := r.output(context.Background(), repoPath, Credentials{}, "status", "--porcelain")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out) != "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) run(ctx context.Context, dir string, creds Credentials, args ...string) error {
|
||||||
|
out, err := r.command(ctx, dir, creds, args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("git %s failed: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) output(ctx context.Context, dir string, creds Credentials, args ...string) (string, error) {
|
||||||
|
out, err := r.command(ctx, dir, creds, args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("git %s failed: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) command(ctx context.Context, dir string, creds Credentials, args ...string) *exec.Cmd {
|
||||||
|
fullArgs := append([]string{"-c", "credential.helper=", "-c", "credential.useHttpPath=true"}, args...)
|
||||||
|
cmd := exec.CommandContext(ctx, "git", fullArgs...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||||
|
if creds.Username != "" || creds.Secret != "" {
|
||||||
|
scriptPath := askPassScript()
|
||||||
|
_ = ensureAskPassScript(scriptPath)
|
||||||
|
cmd.Env = append(
|
||||||
|
cmd.Env,
|
||||||
|
"GIT_ASKPASS="+scriptPath,
|
||||||
|
"GITEA_USERNAME="+creds.Username,
|
||||||
|
"GITEA_PASSWORD="+creds.Secret,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func askPassScript() string {
|
||||||
|
return filepath.Join(os.TempDir(), "sgg-ai-skill-manager-git-askpass.cmd")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureAskPassScript(path string) error {
|
||||||
|
content := "@echo off\r\nset prompt=%~1\r\necho %prompt% | findstr /I \"username\" >nul\r\nif %ERRORLEVEL%==0 (echo %GITEA_USERNAME%) else (echo %GITEA_PASSWORD%)\r\n"
|
||||||
|
return os.WriteFile(path, []byte(content), 0o600)
|
||||||
|
}
|
||||||
110
internal/gitops/git_test.go
Normal file
110
internal/gitops/git_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package gitops
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocalCommitAndDirtyStatus(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runner := New()
|
||||||
|
initRepo(t, dir)
|
||||||
|
writeFile(t, filepath.Join(dir, "SKILL.md"), "# Skill\n")
|
||||||
|
runGit(t, dir, "add", "SKILL.md")
|
||||||
|
runGit(t, dir, "commit", "-m", "initial")
|
||||||
|
|
||||||
|
commit, err := runner.CurrentCommit(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CurrentCommit returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(commit) != 40 {
|
||||||
|
t.Fatalf("commit length = %d, want 40", len(commit))
|
||||||
|
}
|
||||||
|
|
||||||
|
dirty, err := runner.HasLocalChanges(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HasLocalChanges returned error: %v", err)
|
||||||
|
}
|
||||||
|
if dirty {
|
||||||
|
t.Fatal("repo should be clean")
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(dir, "SKILL.md"), "# Changed\n")
|
||||||
|
dirty, err = runner.HasLocalChanges(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HasLocalChanges returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !dirty {
|
||||||
|
t.Fatal("repo should be dirty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClonePullAndRemoteCommit(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
source := filepath.Join(root, "source")
|
||||||
|
remote := filepath.Join(root, "remote.git")
|
||||||
|
clone := filepath.Join(root, "clone")
|
||||||
|
runner := New()
|
||||||
|
|
||||||
|
initRepo(t, source)
|
||||||
|
writeFile(t, filepath.Join(source, "SKILL.md"), "# Skill\n")
|
||||||
|
runGit(t, source, "add", "SKILL.md")
|
||||||
|
runGit(t, source, "commit", "-m", "initial")
|
||||||
|
runGit(t, root, "clone", "--bare", source, remote)
|
||||||
|
|
||||||
|
if err := runner.Clone(context.Background(), remote, clone, Credentials{}); err != nil {
|
||||||
|
t.Fatalf("Clone returned error: %v", err)
|
||||||
|
}
|
||||||
|
remoteCommit, err := runner.RemoteCommit(context.Background(), clone, "main", Credentials{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoteCommit returned error: %v", err)
|
||||||
|
}
|
||||||
|
current, err := runner.CurrentCommit(clone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CurrentCommit returned error: %v", err)
|
||||||
|
}
|
||||||
|
if remoteCommit != current {
|
||||||
|
t.Fatalf("remote commit = %q, current = %q", remoteCommit, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(source, "README.md"), "update\n")
|
||||||
|
runGit(t, source, "add", "README.md")
|
||||||
|
runGit(t, source, "commit", "-m", "update")
|
||||||
|
runGit(t, source, "push", remote, "main")
|
||||||
|
|
||||||
|
if err := runner.Pull(context.Background(), clone, Credentials{}); err != nil {
|
||||||
|
t.Fatalf("Pull returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(clone, "README.md")); err != nil {
|
||||||
|
t.Fatalf("pull did not bring README.md: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initRepo(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "-c", "init.defaultBranch=main", "init")
|
||||||
|
runGit(t, dir, "config", "user.email", "test@example.com")
|
||||||
|
runGit(t, dir, "config", "user.name", "Test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGit(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v failed: %v\n%s", args, err, string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user