From 046400a81ff636db2fbcf0d74f1ae441410c1664 Mon Sep 17 00:00:00 2001 From: "A. Ottr" Date: Sat, 24 Jan 2026 00:31:17 +0100 Subject: [PATCH] refactor: refactor to a more convenient structure --- .github/workflows/release.yml | 31 ++++ .gitignore | 8 +- .goreleaser.yml | 41 +++++ Makefile | 61 +++++++ README.md | 8 +- main.go => cmd/compose-sync/main.go | 71 +++----- internal/core/config.go | 44 +++++ internal/core/git.go | 96 +++++++++++ internal/core/host.go | 50 ++++++ internal/core/lock.go | 30 ++++ internal/stacks/deploy.go | 55 ++++++ internal/stacks/detect.go | 32 ++++ internal/version/version.go | 7 + sync.go | 253 ---------------------------- 14 files changed, 489 insertions(+), 298 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yml create mode 100644 Makefile rename main.go => cmd/compose-sync/main.go (51%) create mode 100644 internal/core/config.go create mode 100644 internal/core/git.go create mode 100644 internal/core/host.go create mode 100644 internal/core/lock.go create mode 100644 internal/stacks/deploy.go create mode 100644 internal/stacks/detect.go create mode 100644 internal/version/version.go delete mode 100644 sync.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6a3fddf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.gitignore b/.gitignore index b8ab85b..dc2952c 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,11 @@ go.work.sum # .idea/ .vscode/ +# Binaries compose-sync -config.yml \ No newline at end of file +dist/ + +# Config +config.yml + +!cmd/compose-sync/ \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..081ec98 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,41 @@ +version: 2 + +project_name: compose-sync + +builds: + - id: compose-sync + main: ./cmd/compose-sync + binary: compose-sync + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + env: + - CGO_ENABLED=0 + ldflags: + - -s -w + - -X github.com/aottr/compose-sync/internal/version.Version={{.Version}} + - -X github.com/aottr/compose-sync/internal/version.Commit={{.Commit}} + - -X github.com/aottr/compose-sync/internal/version.BuildDate={{.Date}} + +archives: + - id: default + files: + - README.md + - LICENSE + - config.yml.example + - systemd/* + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + +release: + github: + owner: aottr + name: compose-sync diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cd954bb --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +.PHONY: build build-version major minor feature + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) + +LDFLAGS = -s -w \ + -X github.com/aottr/compose-sync/internal/version.Version=$(VERSION) \ + -X github.com/aottr/compose-sync/internal/version.Commit=$(COMMIT) \ + -X github.com/aottr/compose-sync/internal/version.BuildDate=$(BUILD_DATE) + +# Get current version tag (removes 'v' prefix if present, defaults to 0.0.0) +CURRENT_TAG := $(shell git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "0.0.0") +CURRENT_MAJOR := $(shell echo "$(CURRENT_TAG)" | awk -F. '{print $$1}' | grep -E '^[0-9]+$$' || echo "0") +CURRENT_MINOR := $(shell echo "$(CURRENT_TAG)" | awk -F. '{print $$2}' | grep -E '^[0-9]+$$' || echo "0") +CURRENT_PATCH := $(shell echo "$(CURRENT_TAG)" | awk -F. '{print $$3}' | grep -E '^[0-9]+$$' || echo "0") + +build: + go build -ldflags "$(LDFLAGS)" -o compose-sync ./cmd/compose-sync + +build-version: build + @./compose-sync -version + +major: + @CURRENT="$(CURRENT_TAG)"; \ + if [ -z "$$CURRENT" ] || [ "$$CURRENT" = "0.0.0" ]; then \ + NEW_VERSION="1.0.0"; \ + else \ + MAJOR=$$(echo "$$CURRENT" | awk -F. '{print $$1}'); \ + NEW_VERSION="$$(($$MAJOR + 1)).0.0"; \ + fi; \ + echo "Bumping major version: $$CURRENT -> $$NEW_VERSION"; \ + git -c tag.gpgSign=false tag -a "v$$NEW_VERSION" -m "Release v$$NEW_VERSION" || git tag "v$$NEW_VERSION"; \ + echo "Created tag v$$NEW_VERSION. Push with: git push origin v$$NEW_VERSION" + +minor: + @CURRENT="$(CURRENT_TAG)"; \ + if [ -z "$$CURRENT" ] || [ "$$CURRENT" = "0.0.0" ]; then \ + NEW_VERSION="0.1.0"; \ + else \ + MAJOR=$$(echo "$$CURRENT" | awk -F. '{print $$1}'); \ + MINOR=$$(echo "$$CURRENT" | awk -F. '{print $$2}'); \ + NEW_VERSION="$$MAJOR.$$(($$MINOR + 1)).0"; \ + fi; \ + echo "Bumping minor version: $$CURRENT -> $$NEW_VERSION"; \ + git -c tag.gpgSign=false tag -a "v$$NEW_VERSION" -m "Release v$$NEW_VERSION" || git tag "v$$NEW_VERSION"; \ + echo "Created tag v$$NEW_VERSION. Push with: git push origin v$$NEW_VERSION" + +patch: + @CURRENT="$(CURRENT_TAG)"; \ + if [ -z "$$CURRENT" ] || [ "$$CURRENT" = "0.0.0" ]; then \ + NEW_VERSION="0.0.1"; \ + else \ + MAJOR=$$(echo "$$CURRENT" | awk -F. '{print $$1}'); \ + MINOR=$$(echo "$$CURRENT" | awk -F. '{print $$2}'); \ + PATCH=$$(echo "$$CURRENT" | awk -F. '{print $$3}'); \ + NEW_VERSION="$$MAJOR.$$MINOR.$$(($$PATCH + 1))"; \ + fi; \ + echo "Bumping patch version: $$CURRENT -> $$NEW_VERSION"; \ + git -c tag.gpgSign=false tag -a "v$$NEW_VERSION" -m "Release v$$NEW_VERSION" || git tag "v$$NEW_VERSION"; \ + echo "Created tag v$$NEW_VERSION. Push with: git push origin v$$NEW_VERSION" diff --git a/README.md b/README.md index a73363c..cbf5418 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,17 @@ hosts: ## Installation +### Using Go Install + ```bash go install github.com/aottr/compose-sync@latest ``` -#### Alternatively build from source +### Using Pre-built Binaries + +Download the latest release from the [Releases page](https://github.com/aottr/compose-sync/releases). + +### Build from Source 1. Clone this repository 2. Build the application: diff --git a/main.go b/cmd/compose-sync/main.go similarity index 51% rename from main.go rename to cmd/compose-sync/main.go index f19d873..2727c5f 100644 --- a/main.go +++ b/cmd/compose-sync/main.go @@ -7,21 +7,31 @@ import ( "os" "path/filepath" "slices" - "sync" + + "github.com/aottr/compose-sync/internal/core" + "github.com/aottr/compose-sync/internal/stacks" + "github.com/aottr/compose-sync/internal/version" ) func main() { configPath := flag.String("config", "config.yml", "Path to configuration file") + showVersion := flag.Bool("version", false, "Show version information") flag.Parse() - cfg, err := loadConfig(*configPath) + if *showVersion { + fmt.Printf("compose-sync version %s\n", version.Version) + fmt.Printf("Commit: %s\n", version.Commit) + fmt.Printf("Build date: %s\n", version.BuildDate) + os.Exit(0) + } + + cfg, err := core.LoadConfig(*configPath) if err != nil { log.Fatalf("Failed to load config: %v", err) } - // Acquire file lock to prevent concurrent sync jobs lockPath := filepath.Join(cfg.RepoPath, ".compose-sync.lock") - releaseLock, err := acquireLock(lockPath) + releaseLock, err := core.AcquireLock(lockPath) if err != nil { log.Fatalf("Failed to acquire lock: %v", err) } @@ -31,32 +41,36 @@ func main() { } }() - currentHost, err := detectHost() + currentHost, err := core.DetectHost() if err != nil { log.Fatalf("Failed to detect host: %v", err) } fmt.Printf("Detected host: %s\n", currentHost) fmt.Printf("Pulling git repository (branch: %s)...\n", cfg.Branch) - changedStacks, err := pullAndDetectChanges(cfg.RepoPath, cfg.Branch) + changedFiles, err := core.PullAndDetectChanges(cfg.RepoPath, cfg.Branch) if err != nil { log.Fatalf("Failed to pull or detect changes: %v", err) } - assignedStacks, err := getAssignedStacks(cfg.RepoPath, currentHost) + if len(changedFiles) == 0 { + fmt.Println("No changes detected.") + return + } + + changedStacks, err := stacks.DetectChangedStacks(changedFiles) if err != nil { - log.Fatalf("Failed to get assigned stacks: %v", err) + log.Fatalf("Failed to detect changed stacks: %v", err) } - fmt.Printf("Stacks assigned to this host: %v\n", assignedStacks) - if len(changedStacks) == 0 { - fmt.Println("No changes detected.") - return + assignedStacks, err := core.GetAssignedStacks(cfg.RepoPath, currentHost) + if err != nil { + log.Fatalf("Failed to get assigned stacks: %v", err) } + fmt.Printf("Stacks assigned to this host: %v\n", assignedStacks) fmt.Printf("Changed stacks: %v\n", changedStacks) - // Filter to only stacks assigned to this host stacksToDeploy := []string{} for _, stack := range changedStacks { if slices.Contains(assignedStacks, stack) { @@ -71,34 +85,5 @@ func main() { fmt.Printf("Stacks to deploy: %v\n", stacksToDeploy) - deployStacks(cfg.RepoPath, stacksToDeploy, cfg.Concurrency) -} - -func deployStacks(repoPath string, stacks []string, concurrency int) { - // Create a semaphore channel to limit concurrency - semaphore := make(chan struct{}, concurrency) - var wg sync.WaitGroup - - for _, stack := range stacks { - wg.Add(1) - go func(stack string) { - defer wg.Done() - - semaphore <- struct{}{} - defer func() { <-semaphore }() - - composePath := filepath.Join(repoPath, "stacks", stack, "compose.yml") - if _, err := os.Stat(composePath); os.IsNotExist(err) { - composePath = filepath.Join(repoPath, "stacks", stack, "compose.yaml") - } - - fmt.Printf("Deploying stack: %s\n", stack) - if err := deployStack(composePath); err != nil { - log.Printf("Failed to deploy stack %s: %v", stack, err) - return - } - fmt.Printf("Successfully deployed stack: %s\n", stack) - }(stack) - } - wg.Wait() + stacks.DeployStacks(cfg.RepoPath, stacksToDeploy, cfg.Concurrency) } diff --git a/internal/core/config.go b/internal/core/config.go new file mode 100644 index 0000000..83eb038 --- /dev/null +++ b/internal/core/config.go @@ -0,0 +1,44 @@ +package core + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + RepoURL string `yaml:"repo_url"` + RepoPath string `yaml:"repo_path"` + Branch string `yaml:"branch"` + Concurrency int `yaml:"concurrency"` +} + +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + if cfg.RepoPath == "" { + return nil, fmt.Errorf("repo_path is required in config") + } + + if cfg.Branch == "" { + cfg.Branch = detectCurrentBranch(cfg.RepoPath) + if cfg.Branch == "" { + cfg.Branch = "main" + } + } + + if cfg.Concurrency <= 0 { + cfg.Concurrency = 3 + } + + return &cfg, nil +} diff --git a/internal/core/git.go b/internal/core/git.go new file mode 100644 index 0000000..2e8804f --- /dev/null +++ b/internal/core/git.go @@ -0,0 +1,96 @@ +package core + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +func PullAndDetectChanges(repoPath, branch string) ([]string, error) { + if err := ensureGitRepository(repoPath); err != nil { + return nil, err + } + + prevHead, err := getGitHead(repoPath) + if err != nil { + return nil, fmt.Errorf("failed to get previous HEAD: %w", err) + } + + if err := gitFetchPull(repoPath, branch); err != nil { + return nil, fmt.Errorf("failed to pull: %w", err) + } + + newHead, err := getGitHead(repoPath) + if err != nil { + return nil, fmt.Errorf("failed to get new HEAD: %w", err) + } + if prevHead == newHead { + return []string{}, nil + } + + output, err := gitCommand(repoPath, "diff", "--name-only", prevHead, newHead) + if err != nil { + return nil, fmt.Errorf("failed to get changed files: %w", err) + } + changedFiles := strings.Split(strings.TrimSpace(string(output)), "\n") + return changedFiles, nil +} + +/** + * Git helper functions + */ + +func gitCommand(repoPath string, args ...string) ([]byte, error) { + cmd := exec.Command("git", args...) + cmd.Dir = repoPath + return cmd.Output() +} + +func detectCurrentBranch(repoPath string) string { + output, err := gitCommand(repoPath, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +func ensureGitRepository(repoPath string) error { + if _, err := os.Stat(repoPath); os.IsNotExist(err) { + return fmt.Errorf("repository path does not exist: %s", repoPath) + } + + if _, err := os.Stat(fmt.Sprintf("%s/.git", repoPath)); os.IsNotExist(err) { + return fmt.Errorf("path is not a git repository: %s", repoPath) + } + return nil +} + +func gitFetchPull(repoPath, branch string) error { + if _, err := gitCommand(repoPath, "fetch", "origin", branch); err != nil { + return fmt.Errorf("git fetch failed: %w", err) + } + + currentBranch, _ := gitCommand(repoPath, "rev-parse", "--abbrev-ref", "HEAD") + if strings.TrimSpace(string(currentBranch)) != branch { + if _, err := gitCommand(repoPath, "checkout", branch); err != nil { + return fmt.Errorf("git checkout failed: %w", err) + } + } + + cmd := exec.Command("git", "pull", "origin", branch) + cmd.Dir = repoPath + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git pull failed: %s, %w", string(output), err) + } + return nil +} + +func getGitHead(repoPath string) (string, error) { + output, err := gitCommand(repoPath, "rev-parse", "HEAD") + if err != nil { + return "", fmt.Errorf("failed to get HEAD: %w", err) + } + return strings.TrimSpace(string(output)), nil +} diff --git a/internal/core/host.go b/internal/core/host.go new file mode 100644 index 0000000..b5d539f --- /dev/null +++ b/internal/core/host.go @@ -0,0 +1,50 @@ +package core + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +func DetectHost() (string, error) { + hostname, err := os.Hostname() + if err != nil { + return "", fmt.Errorf("failed to get hostname: %w", err) + } + return hostname, nil +} + +type inventory struct { + Hosts map[string][]string `yaml:"hosts"` +} + +func GetAssignedStacks(repoPath, hostname string) ([]string, error) { + inventoryFile := filepath.Join(repoPath, "inventory.yml") + + data, err := os.ReadFile(inventoryFile) + if err != nil { + return []string{}, fmt.Errorf("failed to read inventory file %s: %w", inventoryFile, err) + } + + var inv inventory + if err := yaml.Unmarshal(data, &inv); err != nil { + return []string{}, fmt.Errorf("failed to parse inventory file %s: %w", inventoryFile, err) + } + + if inv.Hosts == nil { + return []string{}, fmt.Errorf("inventory file %s has no 'hosts' key", inventoryFile) + } + + stacks, exists := inv.Hosts[hostname] + if !exists { + return []string{}, nil + } + + if stacks == nil { + return []string{}, nil + } + + return stacks, nil +} diff --git a/internal/core/lock.go b/internal/core/lock.go new file mode 100644 index 0000000..c33d11c --- /dev/null +++ b/internal/core/lock.go @@ -0,0 +1,30 @@ +package core + +import ( + "errors" + "os" + "syscall" +) + +func AcquireLock(lockPath string) (func() error, error) { + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return nil, err + } + + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = f.Close() + return nil, errors.New("another compose-sync is already running") + } + + return func() error { + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_UN); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Remove(lockPath) + }, nil +} diff --git a/internal/stacks/deploy.go b/internal/stacks/deploy.go new file mode 100644 index 0000000..3fdbded --- /dev/null +++ b/internal/stacks/deploy.go @@ -0,0 +1,55 @@ +package stacks + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "sync" +) + +func DeployStacks(repoPath string, stacks []string, concurrency int) { + semaphore := make(chan struct{}, concurrency) + var wg sync.WaitGroup + + for _, stack := range stacks { + wg.Add(1) + go func(stack string) { + defer wg.Done() + + semaphore <- struct{}{} + defer func() { <-semaphore }() + + composePath := filepath.Join(repoPath, "stacks", stack, "compose.yml") + if _, err := os.Stat(composePath); os.IsNotExist(err) { + composePath = filepath.Join(repoPath, "stacks", stack, "compose.yaml") + } + + fmt.Printf("Deploying stack: %s\n", stack) + if err := deployStack(composePath); err != nil { + log.Printf("Failed to deploy stack %s: %v", stack, err) + return + } + fmt.Printf("Successfully deployed stack: %s\n", stack) + }(stack) + } + wg.Wait() +} + +func deployStack(composePath string) error { + if _, err := os.Stat(composePath); os.IsNotExist(err) { + return fmt.Errorf("compose file does not exist: %s", composePath) + } + + composeDir := filepath.Dir(composePath) + + cmd := exec.Command("docker", "compose", "-f", composePath, "up", "-d") + cmd.Dir = composeDir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("docker compose up failed: %s, %w", string(output), err) + } + + return nil +} diff --git a/internal/stacks/detect.go b/internal/stacks/detect.go new file mode 100644 index 0000000..6a845c2 --- /dev/null +++ b/internal/stacks/detect.go @@ -0,0 +1,32 @@ +package stacks + +import ( + "strings" +) + +func DetectChangedStacks(changedFiles []string) ([]string, error) { + stackSet := make(map[string]bool) + + for _, file := range changedFiles { + if file == "" { + continue + } + + if strings.HasPrefix(file, "stacks/") { + parts := strings.Split(file, "/") + if len(parts) >= 2 { + stackName := parts[1] + if len(parts) >= 3 && (parts[2] == "compose.yml" || parts[2] == "compose.yaml") { + stackSet[stackName] = true + } + } + } + } + + stacks := make([]string, 0, len(stackSet)) + for stack := range stackSet { + stacks = append(stacks, stack) + } + + return stacks, nil +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..473dce9 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,7 @@ +package version + +var ( + Version = "dev" + Commit = "unknown" + BuildDate = "unknown" +) diff --git a/sync.go b/sync.go deleted file mode 100644 index 63883c9..0000000 --- a/sync.go +++ /dev/null @@ -1,253 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - - "gopkg.in/yaml.v3" -) - -type config struct { - RepoURL string `yaml:"repo_url"` - RepoPath string `yaml:"repo_path"` - Branch string `yaml:"branch"` - Concurrency int `yaml:"concurrency"` -} - -func loadConfig(path string) (*config, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("failed to read config file: %w", err) - } - - var cfg config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("failed to parse config: %w", err) - } - - if cfg.RepoPath == "" { - return nil, fmt.Errorf("repo_path is required in config") - } - - if cfg.Branch == "" { - cfg.Branch = detectCurrentBranch(cfg.RepoPath) - if cfg.Branch == "" { - cfg.Branch = "main" - } - } - - if cfg.Concurrency <= 0 { - cfg.Concurrency = 3 // Safe default for Docker - } - - return &cfg, nil -} - -func acquireLock(lockPath string) (func() error, error) { - f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) - if err != nil { - return nil, err - } - - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - _ = f.Close() - return nil, errors.New("another compose-sync is already running") - } - - return func() error { - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_UN); err != nil { - f.Close() - return err - } - if err := f.Close(); err != nil { - return err - } - return os.Remove(lockPath) - }, nil -} - -func detectHost() (string, error) { - hostname, err := os.Hostname() - if err != nil { - return "", fmt.Errorf("failed to get hostname: %w", err) - } - return hostname, nil -} - -type inventory struct { - Hosts map[string][]string `yaml:"hosts"` -} - -func getAssignedStacks(repoPath, hostname string) ([]string, error) { - inventoryFile := filepath.Join(repoPath, "inventory.yml") - - data, err := os.ReadFile(inventoryFile) - if err != nil { - return []string{}, fmt.Errorf("failed to read inventory file %s: %w", inventoryFile, err) - } - - var inv inventory - if err := yaml.Unmarshal(data, &inv); err != nil { - return []string{}, fmt.Errorf("failed to parse inventory file %s: %w", inventoryFile, err) - } - - if inv.Hosts == nil { - return []string{}, fmt.Errorf("inventory file %s has no 'hosts' key", inventoryFile) - } - - stacks, exists := inv.Hosts[hostname] - if !exists { - return []string{}, nil // Host not in inventory, no stacks assigned - } - - if stacks == nil { - return []string{}, nil - } - - return stacks, nil -} - -func detectCurrentBranch(repoPath string) string { - cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") - cmd.Dir = repoPath - output, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(output)) -} - -func pullAndDetectChanges(repoPath, branch string) ([]string, error) { - if _, err := os.Stat(repoPath); os.IsNotExist(err) { - return nil, fmt.Errorf("repository path does not exist: %s", repoPath) - } - - if _, err := os.Stat(fmt.Sprintf("%s/.git", repoPath)); os.IsNotExist(err) { - return nil, fmt.Errorf("path is not a git repository: %s", repoPath) - } - - prevHead, err := getGitHead(repoPath) - if err != nil { - return nil, fmt.Errorf("failed to get previous HEAD: %w", err) - } - - if err := gitFetchPull(repoPath, branch); err != nil { - return nil, fmt.Errorf("failed to pull: %w", err) - } - - newHead, err := getGitHead(repoPath) - if err != nil { - return nil, fmt.Errorf("failed to get new HEAD: %w", err) - } - - // If HEAD didnt change, no changes were pulled - if prevHead == newHead { - return []string{}, nil - } - - changedStacks, err := findChangedStacks(repoPath, prevHead, newHead) - if err != nil { - return nil, fmt.Errorf("failed to find changed stacks: %w", err) - } - - return changedStacks, nil -} - -func gitFetchPull(repoPath, branch string) error { - cmd := exec.Command("git", "fetch", "origin", branch) - cmd.Dir = repoPath - if err := cmd.Run(); err != nil { - return fmt.Errorf("git fetch failed: %w", err) - } - - cmd = exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") - cmd.Dir = repoPath - currentBranch, _ := cmd.Output() - if strings.TrimSpace(string(currentBranch)) != branch { - cmd = exec.Command("git", "checkout", branch) - cmd.Dir = repoPath - if err := cmd.Run(); err != nil { - return fmt.Errorf("git checkout failed: %w", err) - } - } - - cmd = exec.Command("git", "pull", "origin", branch) - cmd.Dir = repoPath - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git pull failed: %s, %w", string(output), err) - } - return nil -} - -func getGitHead(repoPath string) (string, error) { - cmd := exec.Command("git", "rev-parse", "HEAD") - cmd.Dir = repoPath - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to get HEAD: %w", err) - } - return strings.TrimSpace(string(output)), nil -} - -func findChangedStacks(repoPath, oldCommit, newCommit string) ([]string, error) { - // Get list of changed files between the two commits - cmd := exec.Command("git", "diff", "--name-only", oldCommit, newCommit) - cmd.Dir = repoPath - output, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("failed to get changed files: %w", err) - } - - // Parse changed files and get stack names - changedFiles := strings.Split(strings.TrimSpace(string(output)), "\n") - stackSet := make(map[string]bool) - - for _, file := range changedFiles { - if file == "" { - continue - } - - // Check if file is in the stacks directory - // Format: stacks//compose.yml - if strings.HasPrefix(file, "stacks/") { - parts := strings.Split(file, "/") - if len(parts) >= 2 { - stackName := parts[1] - // filter out unknown files - if len(parts) >= 3 && (parts[2] == "compose.yml" || parts[2] == "compose.yaml") { - stackSet[stackName] = true - } - } - } - } - - stacks := make([]string, 0, len(stackSet)) - for stack := range stackSet { - stacks = append(stacks, stack) - } - - return stacks, nil -} - -func deployStack(composePath string) error { - if _, err := os.Stat(composePath); os.IsNotExist(err) { - return fmt.Errorf("compose file does not exist: %s", composePath) - } - - composeDir := filepath.Dir(composePath) - - cmd := exec.Command("docker", "compose", "-f", composePath, "up", "-d") - cmd.Dir = composeDir - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("docker compose up failed: %s, %w", string(output), err) - } - - return nil -} -- 2.51.2