- Update control plane phase roadmap - Move repo-registry milestone to archive - Implement git engine command execution - Add postgres repository storage layer - Update contracts documentation - Add test cases for storage and git engine
526 lines
15 KiB
Go
526 lines
15 KiB
Go
package gitengine
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type fakeRunner struct {
|
|
outputs []string
|
|
errs []error
|
|
calls []fakeCall
|
|
}
|
|
|
|
type fakeCall struct {
|
|
workdir string
|
|
args []string
|
|
}
|
|
|
|
func (f *fakeRunner) Run(workdir string, args ...string) (string, error) {
|
|
f.calls = append(f.calls, fakeCall{
|
|
workdir: workdir,
|
|
args: append([]string(nil), args...),
|
|
})
|
|
callIndex := len(f.calls) - 1
|
|
var output string
|
|
if callIndex < len(f.outputs) {
|
|
output = f.outputs[callIndex]
|
|
}
|
|
var err error
|
|
if callIndex < len(f.errs) {
|
|
err = f.errs[callIndex]
|
|
}
|
|
return output, err
|
|
}
|
|
|
|
func TestStatusClean(t *testing.T) {
|
|
runner := &fakeRunner{outputs: []string{"\n"}}
|
|
result, err := Status(runner, "/repo")
|
|
if err != nil {
|
|
t.Fatalf("Status: %v", err)
|
|
}
|
|
if !result.Clean {
|
|
t.Fatal("expected clean status")
|
|
}
|
|
if !reflect.DeepEqual(runner.calls[0].args, []string{"status", "--porcelain"}) {
|
|
t.Fatalf("args: %#v", runner.calls[0].args)
|
|
}
|
|
}
|
|
|
|
func TestDiff(t *testing.T) {
|
|
runner := &fakeRunner{outputs: []string{"diff output"}}
|
|
got, err := Diff(runner, "/repo", DiffOptions{})
|
|
if err != nil {
|
|
t.Fatalf("Diff: %v", err)
|
|
}
|
|
if got != "diff output" {
|
|
t.Fatalf("diff output: got %q", got)
|
|
}
|
|
want := []fakeCall{
|
|
{workdir: "/repo", args: []string{"diff"}},
|
|
}
|
|
if !reflect.DeepEqual(runner.calls, want) {
|
|
t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want)
|
|
}
|
|
}
|
|
|
|
func TestDiffStagedPathspecs(t *testing.T) {
|
|
runner := &fakeRunner{}
|
|
_, err := Diff(runner, "/repo", DiffOptions{
|
|
Staged: true,
|
|
Pathspecs: []string{" README.md ", "", "services/core/main.go"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Diff: %v", err)
|
|
}
|
|
want := []fakeCall{
|
|
{workdir: "/repo", args: []string{"diff", "--cached", "--", "README.md", "services/core/main.go"}},
|
|
}
|
|
if !reflect.DeepEqual(runner.calls, want) {
|
|
t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want)
|
|
}
|
|
}
|
|
|
|
func TestDiffRequiresWorkdir(t *testing.T) {
|
|
_, err := Diff(&fakeRunner{}, "", DiffOptions{})
|
|
if !errors.Is(err, ErrInvalidGitInput) {
|
|
t.Fatalf("expected ErrInvalidGitInput, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCloneFetchCheckout(t *testing.T) {
|
|
runner := &fakeRunner{}
|
|
if err := Clone(runner, CloneOptions{
|
|
RemoteURL: "ssh://git.example/nomadcode.git",
|
|
WorktreePath: "/workspace/nomadcode/000",
|
|
Branch: "develop",
|
|
}); err != nil {
|
|
t.Fatalf("Clone: %v", err)
|
|
}
|
|
if err := Fetch(runner, "/workspace/nomadcode/000", ""); err != nil {
|
|
t.Fatalf("Fetch: %v", err)
|
|
}
|
|
if err := Checkout(runner, "/workspace/nomadcode/000", "feature/a"); err != nil {
|
|
t.Fatalf("Checkout: %v", err)
|
|
}
|
|
|
|
want := []fakeCall{
|
|
{args: []string{"clone", "--branch", "develop", "ssh://git.example/nomadcode.git", "/workspace/nomadcode/000"}},
|
|
{workdir: "/workspace/nomadcode/000", args: []string{"fetch", "--prune", "origin"}},
|
|
{workdir: "/workspace/nomadcode/000", args: []string{"checkout", "feature/a"}},
|
|
}
|
|
if !reflect.DeepEqual(runner.calls, want) {
|
|
t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want)
|
|
}
|
|
}
|
|
|
|
func TestCommitAndPush(t *testing.T) {
|
|
runner := &fakeRunner{}
|
|
if err := Commit(runner, "/repo", CommitOptions{Message: "sync roadmap", All: true}); err != nil {
|
|
t.Fatalf("Commit: %v", err)
|
|
}
|
|
if err := Push(runner, "/repo", "", "develop"); err != nil {
|
|
t.Fatalf("Push: %v", err)
|
|
}
|
|
want := []fakeCall{
|
|
{workdir: "/repo", args: []string{"add", "-A"}},
|
|
{workdir: "/repo", args: []string{"commit", "-m", "sync roadmap"}},
|
|
{workdir: "/repo", args: []string{"push", "origin", "develop"}},
|
|
}
|
|
if !reflect.DeepEqual(runner.calls, want) {
|
|
t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want)
|
|
}
|
|
}
|
|
|
|
func TestHeadRevision(t *testing.T) {
|
|
runner := &fakeRunner{outputs: []string{"abc123\n"}}
|
|
got, err := HeadRevision(runner, "/repo")
|
|
if err != nil {
|
|
t.Fatalf("HeadRevision: %v", err)
|
|
}
|
|
if got != "abc123" {
|
|
t.Fatalf("revision: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestChangedFiles(t *testing.T) {
|
|
runner := &fakeRunner{outputs: []string{"README.md\nservices/core/main.go\n\n"}}
|
|
files, err := ChangedFiles(runner, "/repo", "abc", "def")
|
|
if err != nil {
|
|
t.Fatalf("ChangedFiles: %v", err)
|
|
}
|
|
want := []string{"README.md", "services/core/main.go"}
|
|
if !reflect.DeepEqual(files, want) {
|
|
t.Fatalf("files: got %#v want %#v", files, want)
|
|
}
|
|
}
|
|
|
|
func TestChangedFilesRequiresRevisions(t *testing.T) {
|
|
_, err := ChangedFiles(&fakeRunner{}, "/repo", "", "def")
|
|
if !errors.Is(err, ErrInvalidGitInput) {
|
|
t.Fatalf("expected ErrInvalidGitInput, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestChangedFilesWithStatus(t *testing.T) {
|
|
runner := &fakeRunner{outputs: []string{"A\tREADME.md\nM\tservices/core/main.go\nD\told.md\nR100\tfrom.md\tto.md\n"}}
|
|
files, err := ChangedFilesWithStatus(runner, "/repo", "abc", "def")
|
|
if err != nil {
|
|
t.Fatalf("ChangedFilesWithStatus: %v", err)
|
|
}
|
|
want := []ChangedFile{
|
|
{Path: "README.md", ChangeType: "added"},
|
|
{Path: "services/core/main.go", ChangeType: "modified"},
|
|
{Path: "old.md", ChangeType: "deleted"},
|
|
{Path: "to.md", ChangeType: "renamed"},
|
|
}
|
|
if !reflect.DeepEqual(files, want) {
|
|
t.Fatalf("files:\n got %#v\nwant %#v", files, want)
|
|
}
|
|
}
|
|
|
|
func runGit(t *testing.T, dir string, args ...string) {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %s failed in %s: %v\nOutput: %s", strings.Join(args, " "), dir, err, string(out))
|
|
}
|
|
}
|
|
|
|
func gitOutput(t *testing.T, dir string, args ...string) string {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %s failed in %s: %v\nOutput: %s", strings.Join(args, " "), dir, err, string(out))
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
func writeFile(t *testing.T, path, content string) {
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("WriteFile %s failed: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func setupBareRemote(t *testing.T) (barePath, seedPath string, initialRev string) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// 1. Create bare remote repo
|
|
barePath = filepath.Join(tmpDir, "remote.git")
|
|
if err := os.MkdirAll(barePath, 0755); err != nil {
|
|
t.Fatalf("failed to create bare remote dir: %v", err)
|
|
}
|
|
runGit(t, barePath, "init", "--bare")
|
|
// Make sure default branch is main
|
|
runGit(t, barePath, "symbolic-ref", "HEAD", "refs/heads/main")
|
|
|
|
// 2. Create seed repo to initialize the remote
|
|
seedPath = filepath.Join(tmpDir, "seed")
|
|
if err := os.MkdirAll(seedPath, 0755); err != nil {
|
|
t.Fatalf("failed to create seed dir: %v", err)
|
|
}
|
|
runGit(t, seedPath, "init")
|
|
runGit(t, seedPath, "config", "user.name", "Test User")
|
|
runGit(t, seedPath, "config", "user.email", "test@example.com")
|
|
runGit(t, seedPath, "config", "commit.gpgsign", "false")
|
|
|
|
// Create first commit
|
|
writeFile(t, filepath.Join(seedPath, "README.md"), "# Seed Repository\n")
|
|
runGit(t, seedPath, "add", "README.md")
|
|
runGit(t, seedPath, "commit", "-m", "initial commit")
|
|
runGit(t, seedPath, "branch", "-M", "main")
|
|
|
|
// Push to bare remote
|
|
runGit(t, seedPath, "remote", "add", "origin", barePath)
|
|
runGit(t, seedPath, "push", "-u", "origin", "main")
|
|
|
|
initialRev = gitOutput(t, seedPath, "rev-parse", "HEAD")
|
|
return barePath, seedPath, initialRev
|
|
}
|
|
|
|
func TestCloneFetchWithLocalBareRemote(t *testing.T) {
|
|
barePath, seedPath, initialRev := setupBareRemote(t)
|
|
tmpDir := t.TempDir()
|
|
worktreePath := filepath.Join(tmpDir, "worktree")
|
|
|
|
// 1. Clone
|
|
cli := CLI{}
|
|
err := Clone(cli, CloneOptions{
|
|
RemoteURL: barePath,
|
|
WorktreePath: worktreePath,
|
|
Branch: "main",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Clone failed: %v", err)
|
|
}
|
|
|
|
// Verify cloned commit
|
|
rev, err := HeadRevision(cli, worktreePath)
|
|
if err != nil {
|
|
t.Fatalf("HeadRevision failed: %v", err)
|
|
}
|
|
if rev != initialRev {
|
|
t.Fatalf("expected cloned rev %q, got %q", initialRev, rev)
|
|
}
|
|
|
|
// 2. Make new commit in seed and push to bare remote
|
|
writeFile(t, filepath.Join(seedPath, "file.txt"), "hello")
|
|
runGit(t, seedPath, "add", "file.txt")
|
|
runGit(t, seedPath, "commit", "-m", "second commit")
|
|
runGit(t, seedPath, "push", "origin", "main")
|
|
newRev := gitOutput(t, seedPath, "rev-parse", "HEAD")
|
|
|
|
// 3. Fetch in worktree
|
|
err = Fetch(cli, worktreePath, "origin")
|
|
if err != nil {
|
|
t.Fatalf("Fetch failed: %v", err)
|
|
}
|
|
|
|
// Verify origin/main has the new commit
|
|
fetchedRev := gitOutput(t, worktreePath, "rev-parse", "origin/main")
|
|
if fetchedRev != newRev {
|
|
t.Fatalf("expected fetched rev %q, got %q", newRev, fetchedRev)
|
|
}
|
|
}
|
|
|
|
func TestStatusDiffWithCleanStagedUnstagedWorkspace(t *testing.T) {
|
|
barePath, _, _ := setupBareRemote(t)
|
|
tmpDir := t.TempDir()
|
|
worktreePath := filepath.Join(tmpDir, "worktree")
|
|
|
|
cli := CLI{}
|
|
if err := Clone(cli, CloneOptions{RemoteURL: barePath, WorktreePath: worktreePath, Branch: "main"}); err != nil {
|
|
t.Fatalf("Clone failed: %v", err)
|
|
}
|
|
|
|
// 1. Clean workspace
|
|
status, err := Status(cli, worktreePath)
|
|
if err != nil {
|
|
t.Fatalf("Status failed: %v", err)
|
|
}
|
|
if !status.Clean {
|
|
t.Fatalf("expected clean status, got porcelain: %q", status.Output)
|
|
}
|
|
|
|
diff, err := Diff(cli, worktreePath, DiffOptions{})
|
|
if err != nil {
|
|
t.Fatalf("Diff failed: %v", err)
|
|
}
|
|
if diff != "" {
|
|
t.Fatalf("expected empty unstaged diff, got: %q", diff)
|
|
}
|
|
|
|
// 2. Unstaged changes (modified file)
|
|
writeFile(t, filepath.Join(worktreePath, "README.md"), "# Seed Repository\nmodified content\n")
|
|
|
|
status, err = Status(cli, worktreePath)
|
|
if err != nil {
|
|
t.Fatalf("Status failed: %v", err)
|
|
}
|
|
if status.Clean {
|
|
t.Fatal("expected status to be dirty (unstaged changes)")
|
|
}
|
|
if !strings.Contains(status.Output, " README.md") {
|
|
t.Fatalf("expected README.md in status, got: %q", status.Output)
|
|
}
|
|
|
|
diff, err = Diff(cli, worktreePath, DiffOptions{})
|
|
if err != nil {
|
|
t.Fatalf("Diff failed: %v", err)
|
|
}
|
|
if !strings.Contains(diff, "+modified content") {
|
|
t.Fatalf("expected diff to show modifications, got: %q", diff)
|
|
}
|
|
|
|
// Staged diff should be empty
|
|
stagedDiff, err := Diff(cli, worktreePath, DiffOptions{Staged: true})
|
|
if err != nil {
|
|
t.Fatalf("Diff staged failed: %v", err)
|
|
}
|
|
if stagedDiff != "" {
|
|
t.Fatalf("expected empty staged diff, got: %q", stagedDiff)
|
|
}
|
|
|
|
// 3. Staged changes
|
|
runGit(t, worktreePath, "add", "README.md")
|
|
|
|
status, err = Status(cli, worktreePath)
|
|
if err != nil {
|
|
t.Fatalf("Status failed: %v", err)
|
|
}
|
|
if status.Clean {
|
|
t.Fatal("expected status to be dirty (staged changes)")
|
|
}
|
|
|
|
// Unstaged diff should be empty now
|
|
diff, err = Diff(cli, worktreePath, DiffOptions{})
|
|
if err != nil {
|
|
t.Fatalf("Diff failed: %v", err)
|
|
}
|
|
if diff != "" {
|
|
t.Fatalf("expected empty unstaged diff after staging, got: %q", diff)
|
|
}
|
|
|
|
// Staged diff should show modifications
|
|
stagedDiff, err = Diff(cli, worktreePath, DiffOptions{Staged: true})
|
|
if err != nil {
|
|
t.Fatalf("Diff staged failed: %v", err)
|
|
}
|
|
if !strings.Contains(stagedDiff, "+modified content") {
|
|
t.Fatalf("expected staged diff to show modifications, got: %q", stagedDiff)
|
|
}
|
|
}
|
|
|
|
func TestCommitPushToLocalBareRemote(t *testing.T) {
|
|
barePath, _, _ := setupBareRemote(t)
|
|
tmpDir := t.TempDir()
|
|
worktreePath := filepath.Join(tmpDir, "worktree")
|
|
|
|
cli := CLI{}
|
|
if err := Clone(cli, CloneOptions{RemoteURL: barePath, WorktreePath: worktreePath, Branch: "main"}); err != nil {
|
|
t.Fatalf("Clone failed: %v", err)
|
|
}
|
|
|
|
// Configure local git user for the worktree as well
|
|
runGit(t, worktreePath, "config", "user.name", "Test User")
|
|
runGit(t, worktreePath, "config", "user.email", "test@example.com")
|
|
runGit(t, worktreePath, "config", "commit.gpgsign", "false")
|
|
|
|
// Make changes and commit
|
|
writeFile(t, filepath.Join(worktreePath, "newfile.txt"), "hello commit")
|
|
err := Commit(cli, worktreePath, CommitOptions{
|
|
Message: "add newfile",
|
|
All: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Commit failed: %v", err)
|
|
}
|
|
|
|
localRev, err := HeadRevision(cli, worktreePath)
|
|
if err != nil {
|
|
t.Fatalf("HeadRevision failed: %v", err)
|
|
}
|
|
|
|
// Push
|
|
err = Push(cli, worktreePath, "origin", "main")
|
|
if err != nil {
|
|
t.Fatalf("Push failed: %v", err)
|
|
}
|
|
|
|
// Verify bare remote branch has updated to local commit revision
|
|
remoteRev := gitOutput(t, barePath, "rev-parse", "main")
|
|
if remoteRev != localRev {
|
|
t.Fatalf("expected remote revision %q, got %q", localRev, remoteRev)
|
|
}
|
|
}
|
|
|
|
func TestCheckoutBranchFromSourceArgs(t *testing.T) {
|
|
runner := &fakeRunner{}
|
|
|
|
// 1. Simple checkout
|
|
if err := CheckoutBranch(runner, "/repo", CheckoutOptions{Branch: "main"}); err != nil {
|
|
t.Fatalf("CheckoutBranch failed: %v", err)
|
|
}
|
|
|
|
// 2. Checkout with start point
|
|
if err := CheckoutBranch(runner, "/repo", CheckoutOptions{Branch: "feature/a", StartPoint: "main"}); err != nil {
|
|
t.Fatalf("CheckoutBranch failed: %v", err)
|
|
}
|
|
|
|
// 3. Checkout reset with start point
|
|
if err := CheckoutBranch(runner, "/repo", CheckoutOptions{Branch: "feature/b", StartPoint: "main", Reset: true}); err != nil {
|
|
t.Fatalf("CheckoutBranch failed: %v", err)
|
|
}
|
|
|
|
want := []fakeCall{
|
|
{workdir: "/repo", args: []string{"checkout", "main"}},
|
|
{workdir: "/repo", args: []string{"checkout", "-b", "feature/a", "main"}},
|
|
{workdir: "/repo", args: []string{"checkout", "-B", "feature/b", "main"}},
|
|
}
|
|
if !reflect.DeepEqual(runner.calls, want) {
|
|
t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want)
|
|
}
|
|
}
|
|
|
|
func TestCheckoutBranchFromSource(t *testing.T) {
|
|
barePath, _, initialRev := setupBareRemote(t)
|
|
tmpDir := t.TempDir()
|
|
worktreePath := filepath.Join(tmpDir, "worktree")
|
|
|
|
cli := CLI{}
|
|
if err := Clone(cli, CloneOptions{RemoteURL: barePath, WorktreePath: worktreePath, Branch: "main"}); err != nil {
|
|
t.Fatalf("Clone failed: %v", err)
|
|
}
|
|
|
|
// Configure local user
|
|
runGit(t, worktreePath, "config", "user.name", "Test User")
|
|
runGit(t, worktreePath, "config", "user.email", "test@example.com")
|
|
runGit(t, worktreePath, "config", "commit.gpgsign", "false")
|
|
|
|
// Make another commit in main
|
|
writeFile(t, filepath.Join(worktreePath, "main_only.txt"), "main data")
|
|
if err := Commit(cli, worktreePath, CommitOptions{Message: "commit on main", All: true}); err != nil {
|
|
t.Fatalf("Commit failed: %v", err)
|
|
}
|
|
mainRev, err := HeadRevision(cli, worktreePath)
|
|
if err != nil {
|
|
t.Fatalf("HeadRevision failed: %v", err)
|
|
}
|
|
|
|
// 1. Checkout new branch from main's initialRev (start point)
|
|
err = CheckoutBranch(cli, worktreePath, CheckoutOptions{
|
|
Branch: "feature/from-start",
|
|
StartPoint: initialRev,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CheckoutBranch from initialRev failed: %v", err)
|
|
}
|
|
|
|
// Verify we are on feature/from-start, and revision is initialRev (not mainRev)
|
|
currentRev, err := HeadRevision(cli, worktreePath)
|
|
if err != nil {
|
|
t.Fatalf("HeadRevision failed: %v", err)
|
|
}
|
|
if currentRev != initialRev {
|
|
t.Fatalf("expected HEAD revision to be %q, got %q", initialRev, currentRev)
|
|
}
|
|
|
|
// 2. Checkout new branch from main's current mainRev (start point) with Reset: true
|
|
// To test Reset flag (-B), checkout main again
|
|
if err := Checkout(cli, worktreePath, "main"); err != nil {
|
|
t.Fatalf("Checkout main failed: %v", err)
|
|
}
|
|
|
|
// Create branch feature/reset with initialRev first
|
|
err = CheckoutBranch(cli, worktreePath, CheckoutOptions{
|
|
Branch: "feature/reset",
|
|
StartPoint: initialRev,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CheckoutBranch feature/reset failed: %v", err)
|
|
}
|
|
|
|
// Now reset it to mainRev
|
|
err = CheckoutBranch(cli, worktreePath, CheckoutOptions{
|
|
Branch: "feature/reset",
|
|
StartPoint: mainRev,
|
|
Reset: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CheckoutBranch reset failed: %v", err)
|
|
}
|
|
|
|
currentRev, err = HeadRevision(cli, worktreePath)
|
|
if err != nil {
|
|
t.Fatalf("HeadRevision failed: %v", err)
|
|
}
|
|
if currentRev != mainRev {
|
|
t.Fatalf("expected HEAD revision to be reset to %q, got %q", mainRev, currentRev)
|
|
}
|
|
}
|