diff --git a/internal/gitops/git.go b/internal/gitops/git.go new file mode 100644 index 0000000..e2ceb78 --- /dev/null +++ b/internal/gitops/git.go @@ -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) +} diff --git a/internal/gitops/git_test.go b/internal/gitops/git_test.go new file mode 100644 index 0000000..b6d60eb --- /dev/null +++ b/internal/gitops/git_test.go @@ -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)) + } +}