diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2c96bf5..dd49ed2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -84,6 +84,21 @@ jobs:
echo "CC=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
fi
+ - name: Get version from tag
+ id: version
+ run: |
+ # Get version from tag or use git describe
+ if [[ "${{ github.ref }}" == refs/tags/* ]]; then
+ VERSION=${GITHUB_REF#refs/tags/v}
+ else
+ VERSION=$(git describe --tags --always 2>/dev/null || echo "dev")
+ fi
+ COMMIT=$(git rev-parse --short HEAD)
+ DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ echo "commit=$COMMIT" >> $GITHUB_OUTPUT
+ echo "date=$DATE" >> $GITHUB_OUTPUT
+
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
@@ -94,7 +109,9 @@ jobs:
if [ "${{ matrix.goos }}" = "windows" ]; then
BINARY_NAME="${BINARY_NAME}.exe"
fi
- go build -ldflags="-s -w" -o "${BINARY_NAME}"
+
+ LDFLAGS="-s -w -X main.version=${{ steps.version.outputs.version }} -X main.commit=${{ steps.version.outputs.commit }} -X main.date=${{ steps.version.outputs.date }}"
+ go build -ldflags="$LDFLAGS" -o "${BINARY_NAME}"
if [[ "${{ matrix.os }}" == macos* ]]; then
shasum -a 256 "${BINARY_NAME}" > "${BINARY_NAME}.sha256"
@@ -103,7 +120,7 @@ jobs:
fi
- name: Upload artifacts
- if: github.event_name != 'pull_request' # don't upload artifacts for PRs
+ if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
@@ -174,6 +191,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v4
@@ -189,14 +208,60 @@ jobs:
- name: Generate release notes
run: |
- echo "# Vertex Service Manager ${GITHUB_REF#refs/tags/}" > release-notes.md
+ # Get version from tag
+ VERSION=${GITHUB_REF#refs/tags/}
+
+ echo "# Vertex Service Manager $VERSION" > release-notes.md
+ echo "" >> release-notes.md
+
+ # Get commits since last tag
+ PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
+ if [ -n "$PREVIOUS_TAG" ]; then
+ echo "## Changes since $PREVIOUS_TAG" >> release-notes.md
+ echo "" >> release-notes.md
+ git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s (%h)" >> release-notes.md
+ echo "" >> release-notes.md
+ fi
+
echo "" >> release-notes.md
echo "## 🚀 Features" >> release-notes.md
echo "- Complete microservice management platform" >> release-notes.md
echo "- Embedded React web interface" >> release-notes.md
echo "- Cross-platform support" >> release-notes.md
echo "- Real-time monitoring and logs" >> release-notes.md
+ echo "- Profile-based service management" >> release-notes.md
+ echo "- Environment variable management" >> release-notes.md
+ echo "" >> release-notes.md
+ echo "## 📦 Installation" >> release-notes.md
+ echo "" >> release-notes.md
+ echo "### Download Binary" >> release-notes.md
+ echo "" >> release-notes.md
+ echo "Download the appropriate binary for your platform:" >> release-notes.md
+ echo "" >> release-notes.md
+ echo "- **Linux (amd64)**: \`vertex-linux-amd64\`" >> release-notes.md
+ echo "- **Linux (arm64)**: \`vertex-linux-arm64\`" >> release-notes.md
+ echo "- **macOS (Intel)**: \`vertex-darwin-amd64\`" >> release-notes.md
+ echo "- **macOS (Apple Silicon)**: \`vertex-darwin-arm64\`" >> release-notes.md
+ echo "- **Windows (amd64)**: \`vertex-windows-amd64.exe\`" >> release-notes.md
echo "" >> release-notes.md
+ echo "\`\`\`bash" >> release-notes.md
+ echo "# Make executable and install" >> release-notes.md
+ echo "chmod +x vertex-*" >> release-notes.md
+ echo "sudo mv vertex-* /usr/local/bin/vertex" >> release-notes.md
+ echo "" >> release-notes.md
+ echo "# Verify installation" >> release-notes.md
+ echo "vertex version" >> release-notes.md
+ echo "\`\`\`" >> release-notes.md
+ echo "" >> release-notes.md
+ echo "### Docker" >> release-notes.md
+ echo "" >> release-notes.md
+ echo "\`\`\`bash" >> release-notes.md
+ echo "docker pull \${{ secrets.DOCKERHUB_USERNAME }}/vertex:$VERSION" >> release-notes.md
+ echo "# or" >> release-notes.md
+ echo "docker pull \${{ secrets.DOCKERHUB_USERNAME }}/vertex:latest" >> release-notes.md
+ echo "\`\`\`" >> release-notes.md
+
+ cat release-notes.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
diff --git a/Dockerfile b/Dockerfile
index 986aa3d..258afd1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -26,7 +26,9 @@ RUN CGO_ENABLED=1 go build -ldflags="-s -w" -o vertex
FROM alpine:latest
# Install runtime dependencies
-RUN apk --no-cache add ca-certificates sqlite
+# Use --no-scripts to avoid trigger issues in QEMU ARM64 builds
+RUN apk --no-cache --no-scripts add ca-certificates sqlite && \
+ update-ca-certificates 2>/dev/null || true
WORKDIR /app
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..cbf0a48
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,66 @@
+.PHONY: build build-release build-frontend version install clean clean-all help
+
+# Version information
+# Try to get version from git tag, fallback to dev
+GIT_TAG := $(shell git describe --tags --exact-match 2>/dev/null)
+ifneq ($(GIT_TAG),)
+ # If we're on a tag, use it (strip 'v' prefix if present)
+ VERSION ?= $(shell echo $(GIT_TAG) | sed 's/^v//')
+else
+ # Otherwise, try to get the latest tag + commit count
+ GIT_DESCRIBE := $(shell git describe --tags --always 2>/dev/null)
+ ifneq ($(GIT_DESCRIBE),)
+ VERSION ?= $(GIT_DESCRIBE)
+ else
+ VERSION ?= dev
+ endif
+endif
+
+COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
+DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
+
+# Build flags
+LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
+
+help: ## Show this help message
+ @echo 'Usage: make [target]'
+ @echo ''
+ @echo 'Available targets:'
+ @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
+
+build-frontend: ## Build the frontend web application
+ @echo "Building frontend..."
+ @cd web && yarn install --frozen-lockfile && yarn build
+ @echo "✓ Frontend built: web/dist/"
+
+build: build-frontend ## Build vertex with version information (includes frontend)
+ @echo "Building Vertex $(VERSION) ($(COMMIT)) ..."
+ @go build -ldflags="$(LDFLAGS)" -o vertex .
+ @echo "✓ Build complete: ./vertex"
+
+build-release: build-frontend ## Build release version (set VERSION=x.x.x)
+ @if [ "$(VERSION)" = "dev" ]; then \
+ echo "Error: VERSION must be set for release builds"; \
+ echo "Usage: make build-release VERSION=1.0.0"; \
+ exit 1; \
+ fi
+ @echo "Building Vertex $(VERSION) ($(COMMIT)) ..."
+ @go build -ldflags="$(LDFLAGS)" -o vertex .
+ @echo "✓ Release build complete: ./vertex"
+
+version: build ## Build and show version information
+ @./vertex version
+
+install: build ## Build and install vertex
+ @./vertex install
+
+clean: ## Remove Go build artifacts
+ @rm -f vertex vertex-*
+ @echo "✓ Build artifacts removed"
+
+clean-all: clean ## Remove all build artifacts (including frontend)
+ @rm -rf web/dist web/node_modules
+ @echo "✓ All build artifacts removed"
+
+# Quick development build (alias)
+dev: build ## Alias for build
diff --git a/RELEASE.md b/RELEASE.md
new file mode 100644
index 0000000..3b729ae
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,144 @@
+# Release Guide
+
+## Quick Release Process
+
+1. **Commit all changes**
+ ```bash
+ git add .
+ git commit -m "feat: your changes"
+ ```
+
+2. **Create and push version tag**
+ ```bash
+ git tag -a v1.0.0 -m "Release version 1.0.0"
+ git push origin v1.0.0
+ ```
+
+3. **Wait for GitHub Actions**
+ - Workflow automatically builds binaries for all platforms
+ - Creates GitHub Release with binaries and release notes
+ - Check: https://github.com/zechtz/vertex/actions
+
+4. **Done!**
+ - Users can download from: https://github.com/zechtz/vertex/releases
+
+## What Happens Automatically
+
+When you push a tag (e.g., `v1.0.0`):
+
+1. **GitHub Actions triggers** (`.github/workflows/release.yml`)
+2. **Builds binaries** for:
+ - Linux (amd64, arm64)
+ - macOS (Intel, Apple Silicon)
+ - Windows (amd64)
+3. **Generates SHA256 checksums**
+4. **Creates release notes** from git commits
+5. **Publishes GitHub Release** with all artifacts
+
+## Local Testing
+
+Test the build before tagging:
+
+```bash
+# Build locally with current git tag
+make build
+./vertex version
+
+# Build with specific version (override)
+make build VERSION=1.0.0-test
+./vertex version
+```
+
+## Version Numbering
+
+Follow [Semantic Versioning](https://semver.org/):
+
+- `v1.0.0` - Major release (breaking changes)
+- `v1.1.0` - Minor release (new features, backward compatible)
+- `v1.1.1` - Patch release (bug fixes)
+- `v1.0.0-beta.1` - Pre-release
+
+## Example Workflow
+
+```bash
+# 1. Make changes
+vim internal/services/manager.go
+
+# 2. Test locally
+make build
+./vertex version
+
+# 3. Commit
+git add .
+git commit -m "feat: add new service management feature"
+
+# 4. Tag and push
+git tag -a v1.2.0 -m "Release version 1.2.0 - Add service management"
+git push origin main
+git push origin v1.2.0
+
+# 5. Monitor GitHub Actions
+# Visit: https://github.com/zechtz/vertex/actions
+
+# 6. Release is ready!
+# Visit: https://github.com/zechtz/vertex/releases/latest
+```
+
+## Troubleshooting
+
+### Build fails in GitHub Actions
+
+- Check the Actions tab for detailed logs
+- Common issues:
+ - Tests failing
+ - Dependencies not available
+ - Go version mismatch
+
+### Version not detected
+
+```bash
+# Check git tags
+git tag -l
+
+# Check current version
+git describe --tags
+
+# If no tags exist, create one
+git tag -a v0.1.0 -m "Initial release"
+```
+
+### Need to re-release
+
+If you need to fix a release:
+
+```bash
+# Delete the tag locally and remotely
+git tag -d v1.0.0
+git push origin :refs/tags/v1.0.0
+
+# Delete the GitHub Release (via web UI)
+# Make your fixes, commit, and re-tag
+git tag -a v1.0.0 -m "Release version 1.0.0"
+git push origin v1.0.0
+```
+
+## Manual Release (Without CI/CD)
+
+If you need to create a release manually:
+
+```bash
+# Build all platforms
+./build.sh
+
+# This creates:
+# - vertex-linux-amd64
+# - vertex-linux-arm64
+# - vertex-darwin-amd64
+# - vertex-darwin-arm64
+# - vertex-windows-amd64.exe
+
+# Create checksums
+sha256sum vertex-* > checksums.txt
+
+# Manually create GitHub Release and upload files
+```
diff --git a/VERSION.md b/VERSION.md
new file mode 100644
index 0000000..9e55bb5
--- /dev/null
+++ b/VERSION.md
@@ -0,0 +1,141 @@
+# Vertex Versioning
+
+## Automatic Versioning from Git Tags
+
+Vertex automatically detects and uses git tags as version numbers. This integrates seamlessly with CI/CD workflows.
+
+### Quick Start
+
+```bash
+# Development build (automatically uses git tag or "dev")
+make build
+
+# Build and show version
+make version
+
+# Manual version override (if needed)
+make build VERSION=1.0.0
+```
+
+### How Version Detection Works
+
+1. **On a tagged commit**: Uses the git tag (e.g., `v1.0.0` → version `1.0.0`)
+2. **Between tags**: Uses `git describe` output (e.g., `v1.0.0-5-g1234abc`)
+3. **No tags**: Falls back to `dev`
+
+The version is automatically stripped of the `v` prefix if present.
+
+### Using the Build Script
+
+The build script also auto-detects git tags:
+
+```bash
+# Auto-detect version from git tags
+./build.sh
+
+# Manual override if needed
+VERSION=1.0.0 ./build.sh
+```
+
+The build script creates cross-platform binaries.
+
+## Checking Version
+
+Once built, check the version with:
+
+```bash
+./vertex version
+# or
+./vertex --version
+```
+
+Example output:
+```
+Vertex 0.1.0
+Commit: fc8869d
+Built: 2025-12-02T04:59:13Z
+```
+
+## Available Make Targets
+
+- `make help` - Show all available targets
+- `make build` - Build with version info (defaults to "dev")
+- `make build-release VERSION=x.x.x` - Build a release version
+- `make version` - Build and display version
+- `make install` - Build and install vertex
+- `make clean` - Remove build artifacts
+- `make dev` - Alias for `make build`
+
+## Version Information
+
+The version information is embedded at build time and includes:
+
+- **Version**: Semantic version (e.g., 1.0.0) or "dev" for development builds
+- **Commit**: Short git commit hash (e.g., fc8869d)
+- **Built**: Build timestamp in UTC (e.g., 2025-12-02T04:59:13Z)
+
+## Automated CI/CD Release Process
+
+Vertex uses GitHub Actions to automatically build and release binaries when you push a version tag.
+
+### Creating a Release
+
+1. **Commit your changes**:
+ ```bash
+ git add .
+ git commit -m "feat: add new feature"
+ ```
+
+2. **Create and push a version tag**:
+ ```bash
+ git tag -a v1.0.0 -m "Release version 1.0.0"
+ git push origin v1.0.0
+ ```
+
+3. **GitHub Actions automatically**:
+ - Detects the tag push
+ - Builds binaries for all platforms:
+ - Linux (amd64, arm64)
+ - macOS (amd64, arm64)
+ - Windows (amd64)
+ - Generates release notes from commits
+ - Creates checksums
+ - Publishes a GitHub Release with all binaries
+
+4. **Users can download** the pre-built binaries from the GitHub Releases page
+
+### Version Tag Format
+
+Use semantic versioning with a `v` prefix:
+- `v1.0.0` - Major release
+- `v1.1.0` - Minor release (new features)
+- `v1.1.1` - Patch release (bug fixes)
+- `v2.0.0-beta.1` - Pre-release
+
+### Manual Release (Local Build)
+
+If you need to build locally without CI/CD:
+
+```bash
+# Tag your commit
+git tag -a v1.0.0 -m "Release version 1.0.0"
+
+# Build (automatically uses the tag)
+make build
+
+# Or use build script for cross-platform binaries
+./build.sh
+
+# Verify version
+./vertex version
+```
+
+### Release Checklist
+
+- [ ] All tests passing
+- [ ] Version number follows semantic versioning
+- [ ] CHANGELOG updated (if you maintain one)
+- [ ] Create annotated git tag: `git tag -a vX.Y.Z -m "Release version X.Y.Z"`
+- [ ] Push tag: `git push origin vX.Y.Z`
+- [ ] Verify GitHub Actions workflow completes successfully
+- [ ] Check GitHub Releases page for published release
diff --git a/build.sh b/build.sh
index 3262385..3508071 100755
--- a/build.sh
+++ b/build.sh
@@ -3,7 +3,23 @@
# Cross-platform build script for Vertex
set -e
-VERSION=${VERSION:-"dev"}
+# Try to get version from git tag
+if [ -z "$VERSION" ]; then
+ GIT_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
+ if [ -n "$GIT_TAG" ]; then
+ # If we're on a tag, use it (strip 'v' prefix if present)
+ VERSION=$(echo "$GIT_TAG" | sed 's/^v//')
+ else
+ # Otherwise, try to get the latest tag + commit count
+ GIT_DESCRIBE=$(git describe --tags --always 2>/dev/null || echo "")
+ if [ -n "$GIT_DESCRIBE" ]; then
+ VERSION="$GIT_DESCRIBE"
+ else
+ VERSION="dev"
+ fi
+ fi
+fi
+
COMMIT=${COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")}
DATE=${DATE:-$(date -u +"%Y-%m-%dT%H:%M:%SZ")}
diff --git a/internal/handlers/service_handler.go b/internal/handlers/service_handler.go
index d59a47e..85603ef 100644
--- a/internal/handlers/service_handler.go
+++ b/internal/handlers/service_handler.go
@@ -48,6 +48,11 @@ func registerServiceRoutes(h *Handler, r *mux.Router) {
r.HandleFunc("/api/services/{id}/wrapper/generate", h.generateWrapperHandler).Methods("POST")
r.HandleFunc("/api/services/{id}/wrapper/repair", h.repairWrapperHandler).Methods("POST")
+ // Git operations
+ r.HandleFunc("/api/services/{id}/git/info", h.getGitInfoHandler).Methods("GET")
+ r.HandleFunc("/api/services/{id}/git/branches", h.getGitBranchesHandler).Methods("GET")
+ r.HandleFunc("/api/services/{id}/git/switch", h.switchGitBranchHandler).Methods("POST")
+
// Utility endpoints
r.HandleFunc("/api/services/available-for-profile", h.getAvailableServicesForProfileHandler).Methods("GET")
r.HandleFunc("/api/services/normalize-order", h.normalizeServiceOrderHandler).Methods("POST")
@@ -1229,3 +1234,108 @@ func (h *Handler) repairWrapperHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(response)
}
+// Git operation handlers
+
+func (h *Handler) getGitInfoHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+
+ vars := mux.Vars(r)
+ serviceUUID := vars["id"]
+
+ if serviceUUID == "" {
+ http.Error(w, "Service UUID is required", http.StatusBadRequest)
+ return
+ }
+
+ _, exists := h.serviceManager.GetServiceByUUID(serviceUUID)
+ if !exists {
+ http.Error(w, "Service not found", http.StatusNotFound)
+ return
+ }
+
+ gitInfo, err := h.serviceManager.GetGitInfo(serviceUUID)
+ if err != nil {
+ log.Printf("[ERROR] Failed to get git info for service %s: %v", serviceUUID, err)
+ http.Error(w, fmt.Sprintf("Failed to get git info: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ json.NewEncoder(w).Encode(gitInfo)
+}
+
+func (h *Handler) getGitBranchesHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+
+ vars := mux.Vars(r)
+ serviceUUID := vars["id"]
+
+ if serviceUUID == "" {
+ http.Error(w, "Service UUID is required", http.StatusBadRequest)
+ return
+ }
+
+ _, exists := h.serviceManager.GetServiceByUUID(serviceUUID)
+ if !exists {
+ http.Error(w, "Service not found", http.StatusNotFound)
+ return
+ }
+
+ branches, err := h.serviceManager.GetGitBranches(serviceUUID)
+ if err != nil {
+ log.Printf("[ERROR] Failed to get git branches for service %s: %v", serviceUUID, err)
+ http.Error(w, fmt.Sprintf("Failed to get git branches: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "branches": branches,
+ })
+}
+
+func (h *Handler) switchGitBranchHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+
+ vars := mux.Vars(r)
+ serviceUUID := vars["id"]
+
+ if serviceUUID == "" {
+ http.Error(w, "Service UUID is required", http.StatusBadRequest)
+ return
+ }
+
+ _, exists := h.serviceManager.GetServiceByUUID(serviceUUID)
+ if !exists {
+ http.Error(w, "Service not found", http.StatusNotFound)
+ return
+ }
+
+ var req struct {
+ Branch string `json:"branch"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Branch == "" {
+ http.Error(w, "Branch name is required", http.StatusBadRequest)
+ return
+ }
+
+ err := h.serviceManager.SwitchGitBranch(serviceUUID, req.Branch)
+ if err != nil {
+ log.Printf("[ERROR] Failed to switch git branch for service %s: %v", serviceUUID, err)
+ http.Error(w, fmt.Sprintf("Failed to switch branch: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "status": "success",
+ "branch": req.Branch,
+ "message": fmt.Sprintf("Successfully switched to branch '%s'", req.Branch),
+ })
+}
diff --git a/internal/models/service.go b/internal/models/service.go
index d47bbc8..9b3fb46 100644
--- a/internal/models/service.go
+++ b/internal/models/service.go
@@ -25,6 +25,7 @@ type Service struct {
IsEnabled bool `json:"isEnabled"`
BuildSystem string `json:"buildSystem"` // "maven", "gradle", or "auto"
VerboseLogging bool `json:"verboseLogging"` // Enable verbose/debug logging for build tools
+ GitBranch string `json:"gitBranch"` // Current git branch (if service is a git repo)
EnvVars map[string]EnvVar `json:"envVars"`
Cmd *exec.Cmd `json:"-"`
Logs []LogEntry `json:"logs"`
diff --git a/internal/services/buildsystem.go b/internal/services/buildsystem.go
index 4a858df..6075f29 100644
--- a/internal/services/buildsystem.go
+++ b/internal/services/buildsystem.go
@@ -233,7 +233,7 @@ func GenerateMavenWrapper(serviceDir string) error {
log.Printf("[DEBUG] Using %s path: %s", mvnExecutable, mvnPath)
// Verify executable permissions
- if info, err := os.Stat(mvnPath); err != nil || info.Mode().Perm()&0111 == 0 {
+ if info, err := os.Stat(mvnPath); err != nil || info.Mode().Perm()&0o111 == 0 {
return fmt.Errorf("%s path %s is not executable or inaccessible: %v", mvnExecutable, mvnPath, err)
}
@@ -268,7 +268,7 @@ func GenerateGradleWrapper(serviceDir string) error {
// Make gradlew executable on Unix systems
gradlewPath := filepath.Join(serviceDir, "gradlew")
- if err := os.Chmod(gradlewPath, 0755); err != nil {
+ if err := os.Chmod(gradlewPath, 0o755); err != nil {
log.Printf("[WARN] Failed to make gradlew executable: %v", err)
}
diff --git a/internal/services/database.go b/internal/services/database.go
index 79bafa3..8a1637d 100644
--- a/internal/services/database.go
+++ b/internal/services/database.go
@@ -153,9 +153,26 @@ func (sm *Manager) loadServices(config models.Config) error {
}
}
+ // Update git branch information for all services
+ sm.updateAllGitBranches()
+
return nil
}
+// updateAllGitBranches updates git branch information for all loaded services
+func (sm *Manager) updateAllGitBranches() {
+ sm.mutex.RLock()
+ serviceIDs := make([]string, 0, len(sm.services))
+ for id := range sm.services {
+ serviceIDs = append(serviceIDs, id)
+ }
+ sm.mutex.RUnlock()
+
+ for _, serviceID := range serviceIDs {
+ _ = sm.UpdateServiceGitBranch(serviceID)
+ }
+}
+
func (sm *Manager) loadConfigurations() error {
rows, err := sm.db.Query("SELECT id, name, services_json, is_default FROM configurations")
if err != nil {
diff --git a/internal/services/git.go b/internal/services/git.go
new file mode 100644
index 0000000..2664b8c
--- /dev/null
+++ b/internal/services/git.go
@@ -0,0 +1,218 @@
+package services
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// GitInfo holds git repository information
+type GitInfo struct {
+ IsGitRepo bool `json:"isGitRepo"`
+ CurrentBranch string `json:"currentBranch"`
+ Branches []string `json:"branches"`
+ HasUncommitted bool `json:"hasUncommitted"`
+}
+
+// IsGitRepository checks if a directory is a git repository
+func IsGitRepository(dir string) bool {
+ gitDir := filepath.Join(dir, ".git")
+ info, err := os.Stat(gitDir)
+ if err != nil {
+ return false
+ }
+ return info.IsDir()
+}
+
+// GetCurrentBranch returns the current git branch
+func GetCurrentBranch(dir string) (string, error) {
+ if !IsGitRepository(dir) {
+ return "", fmt.Errorf("not a git repository")
+ }
+
+ cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
+ cmd.Dir = dir
+ output, err := cmd.Output()
+ if err != nil {
+ return "", fmt.Errorf("failed to get current branch: %w", err)
+ }
+
+ branch := strings.TrimSpace(string(output))
+ return branch, nil
+}
+
+// GetBranches returns all local branches
+func GetBranches(dir string) ([]string, error) {
+ if !IsGitRepository(dir) {
+ return nil, fmt.Errorf("not a git repository")
+ }
+
+ cmd := exec.Command("git", "branch", "--format=%(refname:short)")
+ cmd.Dir = dir
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get branches: %w", err)
+ }
+
+ branches := []string{}
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line != "" {
+ branches = append(branches, line)
+ }
+ }
+
+ return branches, nil
+}
+
+// GetRemoteBranches returns all remote branches
+func GetRemoteBranches(dir string) ([]string, error) {
+ if !IsGitRepository(dir) {
+ return nil, fmt.Errorf("not a git repository")
+ }
+
+ // Fetch latest from remote
+ fetchCmd := exec.Command("git", "fetch", "--all")
+ fetchCmd.Dir = dir
+ _ = fetchCmd.Run() // Ignore errors, we'll try to get branches anyway
+
+ cmd := exec.Command("git", "branch", "-r", "--format=%(refname:short)")
+ cmd.Dir = dir
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get remote branches: %w", err)
+ }
+
+ branches := []string{}
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line != "" && !strings.Contains(line, "HEAD") {
+ branches = append(branches, line)
+ }
+ }
+
+ return branches, nil
+}
+
+// HasUncommittedChanges checks if there are uncommitted changes
+func HasUncommittedChanges(dir string) (bool, error) {
+ if !IsGitRepository(dir) {
+ return false, fmt.Errorf("not a git repository")
+ }
+
+ cmd := exec.Command("git", "status", "--porcelain")
+ cmd.Dir = dir
+ output, err := cmd.Output()
+ if err != nil {
+ return false, fmt.Errorf("failed to check git status: %w", err)
+ }
+
+ return len(strings.TrimSpace(string(output))) > 0, nil
+}
+
+// SwitchBranch switches to a different branch
+func SwitchBranch(dir, branch string) error {
+ if !IsGitRepository(dir) {
+ return fmt.Errorf("not a git repository")
+ }
+
+ // Check for uncommitted changes
+ hasChanges, err := HasUncommittedChanges(dir)
+ if err != nil {
+ return err
+ }
+
+ if hasChanges {
+ return fmt.Errorf("cannot switch branches: you have uncommitted changes. Please commit or stash them first")
+ }
+
+ // Check if it's a remote branch that doesn't exist locally
+ if strings.HasPrefix(branch, "origin/") {
+ localBranch := strings.TrimPrefix(branch, "origin/")
+
+ // Check if local branch exists
+ branches, err := GetBranches(dir)
+ if err != nil {
+ return err
+ }
+
+ branchExists := false
+ for _, b := range branches {
+ if b == localBranch {
+ branchExists = true
+ break
+ }
+ }
+
+ if !branchExists {
+ // Create and track the remote branch
+ cmd := exec.Command("git", "checkout", "-b", localBranch, branch)
+ cmd.Dir = dir
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("failed to checkout remote branch: %s", string(output))
+ }
+ return nil
+ }
+
+ branch = localBranch
+ }
+
+ // Switch to the branch
+ cmd := exec.Command("git", "checkout", branch)
+ cmd.Dir = dir
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("failed to switch branch: %s", string(output))
+ }
+
+ return nil
+}
+
+// GetGitInfo returns comprehensive git information for a directory
+func GetGitInfo(dir string) (*GitInfo, error) {
+ info := &GitInfo{
+ IsGitRepo: IsGitRepository(dir),
+ }
+
+ if !info.IsGitRepo {
+ return info, nil
+ }
+
+ // Get current branch
+ currentBranch, err := GetCurrentBranch(dir)
+ if err == nil {
+ info.CurrentBranch = currentBranch
+ }
+
+ // Get all branches (local + remote)
+ localBranches, _ := GetBranches(dir)
+ remoteBranches, _ := GetRemoteBranches(dir)
+
+ // Combine and deduplicate
+ branchMap := make(map[string]bool)
+ for _, b := range localBranches {
+ branchMap[b] = true
+ }
+ for _, b := range remoteBranches {
+ branchMap[b] = true
+ }
+
+ branches := []string{}
+ for b := range branchMap {
+ branches = append(branches, b)
+ }
+ info.Branches = branches
+
+ // Check for uncommitted changes
+ hasChanges, err := HasUncommittedChanges(dir)
+ if err == nil {
+ info.HasUncommitted = hasChanges
+ }
+
+ return info, nil
+}
diff --git a/internal/services/manager.go b/internal/services/manager.go
index 5f72a38..d897d69 100644
--- a/internal/services/manager.go
+++ b/internal/services/manager.go
@@ -1000,3 +1000,154 @@ func (sm *Manager) HasMavenWrapper(serviceDir string) bool {
func (sm *Manager) HasGradleWrapper(serviceDir string) bool {
return HasGradleWrapper(serviceDir)
}
+
+// Git-related methods
+
+// GetGitInfo returns git information for a service
+func (sm *Manager) GetGitInfo(serviceUUID string) (*GitInfo, error) {
+ sm.mutex.RLock()
+ service, exists := sm.services[serviceUUID]
+ sm.mutex.RUnlock()
+
+ if !exists {
+ return nil, fmt.Errorf("service UUID %s not found", serviceUUID)
+ }
+
+ // Get the full service directory path
+ projectsDir := sm.getServiceProjectsDirectory(serviceUUID)
+ if projectsDir == "" {
+ projectsDir = sm.config.ProjectsDir
+ }
+
+ fullPath := filepath.Join(projectsDir, service.Dir)
+ return GetGitInfo(fullPath)
+}
+
+// GetGitBranches returns all branches (local and remote) for a service
+func (sm *Manager) GetGitBranches(serviceUUID string) ([]string, error) {
+ sm.mutex.RLock()
+ service, exists := sm.services[serviceUUID]
+ sm.mutex.RUnlock()
+
+ if !exists {
+ return nil, fmt.Errorf("service UUID %s not found", serviceUUID)
+ }
+
+ // Get the full service directory path
+ projectsDir := sm.getServiceProjectsDirectory(serviceUUID)
+ if projectsDir == "" {
+ projectsDir = sm.config.ProjectsDir
+ }
+
+ fullPath := filepath.Join(projectsDir, service.Dir)
+
+ if !IsGitRepository(fullPath) {
+ return nil, fmt.Errorf("service is not a git repository")
+ }
+
+ // Get local branches
+ localBranches, err := GetBranches(fullPath)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get remote branches
+ remoteBranches, err := GetRemoteBranches(fullPath)
+ if err != nil {
+ // If remote fetch fails, just return local branches
+ return localBranches, nil
+ }
+
+ // Combine and deduplicate
+ branchMap := make(map[string]bool)
+ for _, b := range localBranches {
+ branchMap[b] = true
+ }
+ for _, b := range remoteBranches {
+ branchMap[b] = true
+ }
+
+ branches := []string{}
+ for b := range branchMap {
+ branches = append(branches, b)
+ }
+
+ return branches, nil
+}
+
+// SwitchGitBranch switches a service to a different git branch
+func (sm *Manager) SwitchGitBranch(serviceUUID, branch string) error {
+ sm.mutex.RLock()
+ service, exists := sm.services[serviceUUID]
+ sm.mutex.RUnlock()
+
+ if !exists {
+ return fmt.Errorf("service UUID %s not found", serviceUUID)
+ }
+
+ // Check if service is running
+ if service.Status == "running" {
+ return fmt.Errorf("cannot switch branches while service is running. Please stop the service first")
+ }
+
+ // Get the full service directory path
+ projectsDir := sm.getServiceProjectsDirectory(serviceUUID)
+ if projectsDir == "" {
+ projectsDir = sm.config.ProjectsDir
+ }
+
+ fullPath := filepath.Join(projectsDir, service.Dir)
+
+ // Switch branch
+ if err := SwitchBranch(fullPath, branch); err != nil {
+ return err
+ }
+
+ // Update the service's git branch info
+ currentBranch, err := GetCurrentBranch(fullPath)
+ if err == nil {
+ sm.mutex.Lock()
+ service.GitBranch = currentBranch
+ sm.mutex.Unlock()
+
+ // Broadcast update
+ sm.broadcastUpdate(service)
+ }
+
+ log.Printf("[INFO] Successfully switched service %s (UUID: %s) to branch %s", service.Name, serviceUUID, branch)
+ return nil
+}
+
+// UpdateServiceGitBranch updates the git branch information for a service
+func (sm *Manager) UpdateServiceGitBranch(serviceUUID string) error {
+ sm.mutex.RLock()
+ service, exists := sm.services[serviceUUID]
+ sm.mutex.RUnlock()
+
+ if !exists {
+ return fmt.Errorf("service UUID %s not found", serviceUUID)
+ }
+
+ // Get the full service directory path
+ projectsDir := sm.getServiceProjectsDirectory(serviceUUID)
+ if projectsDir == "" {
+ projectsDir = sm.config.ProjectsDir
+ }
+
+ fullPath := filepath.Join(projectsDir, service.Dir)
+
+ if !IsGitRepository(fullPath) {
+ return nil
+ }
+
+ currentBranch, err := GetCurrentBranch(fullPath)
+ if err != nil {
+ return err
+ }
+
+ sm.mutex.Lock()
+ service.GitBranch = currentBranch
+ sm.mutex.Unlock()
+
+ return nil
+}
diff --git a/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx b/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx
new file mode 100644
index 0000000..6df0d72
--- /dev/null
+++ b/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx
@@ -0,0 +1,282 @@
+import { useState, useEffect, useMemo } from "react";
+import { GitBranch, Check, Loader2, Search } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Modal } from "@/components/ui/Modal";
+import { useToast, toast } from "@/components/ui/toast";
+
+interface GitBranchSwitcherProps {
+ serviceId: string;
+ serviceName: string;
+ currentBranch: string;
+ isServiceRunning: boolean;
+}
+
+export function GitBranchSwitcher({
+ serviceId,
+ serviceName,
+ currentBranch,
+ isServiceRunning,
+}: GitBranchSwitcherProps) {
+ const [branches, setBranches] = useState
+ Please stop {serviceName} before switching branches
+