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([]); + const [isLoading, setIsLoading] = useState(false); + const [isSwitching, setIsSwitching] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const { addToast } = useToast(); + + useEffect(() => { + fetchBranches(); + }, [serviceId]); + + const fetchBranches = async () => { + try { + setIsLoading(true); + const token = localStorage.getItem("authToken"); + if (!token) { + throw new Error("No authentication token"); + } + + const response = await fetch(`/api/services/${serviceId}/git/branches`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + if (response.status === 404 || response.status === 500) { + // Service is not a git repository, silently skip + return; + } + throw new Error(`Failed to fetch branches: ${response.statusText}`); + } + + const data = await response.json(); + setBranches(data.branches || []); + } catch (error) { + // Silently ignore errors for non-git services + console.log(`Service ${serviceName} is not a git repository`); + } finally { + setIsLoading(false); + } + }; + + const handleSwitchBranch = async (branch: string) => { + if (branch === currentBranch) { + setIsModalOpen(false); + return; + } + + if (isServiceRunning) { + addToast( + toast.error( + "Cannot switch branches", + `Please stop ${serviceName} before switching branches`, + ), + ); + return; + } + + try { + setIsSwitching(true); + const token = localStorage.getItem("authToken"); + if (!token) { + throw new Error("No authentication token"); + } + + const response = await fetch(`/api/services/${serviceId}/git/switch`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ branch }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `Failed to switch branch`); + } + + await response.json(); + addToast( + toast.success( + "Branch switched", + `${serviceName} is now on branch '${branch}'`, + ), + ); + + setIsModalOpen(false); + // Refresh the page to update the service info + window.location.reload(); + } catch (error) { + console.error("Failed to switch branch:", error); + addToast( + toast.error( + "Failed to switch branch", + error instanceof Error ? error.message : "Unknown error", + ), + ); + } finally { + setIsSwitching(false); + } + }; + + // Filter branches based on search query + const filteredBranches = useMemo(() => { + if (!searchQuery.trim()) { + return branches; + } + const query = searchQuery.toLowerCase(); + return branches.filter((branch) => + branch.toLowerCase().includes(query) + ); + }, [branches, searchQuery]); + + // Don't render if no branches or not a git repo + if (branches.length === 0 && !isLoading) { + return null; + } + + return ( + <> + + + { + setIsModalOpen(false); + setSearchQuery(""); + }} + title={`Switch Git Branch - ${serviceName}`} + size="lg" + contentClassName="p-0" + > +
+ {/* Search Input */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + autoFocus + /> +
+ {searchQuery && ( +
+ Found {filteredBranches.length} of {branches.length} branches +
+ )} +
+ + {/* Branch List */} +
+ {filteredBranches.length === 0 ? ( +
+ {searchQuery ? ( + <> + No branches found matching "{searchQuery}" + + ) : ( + "No branches available" + )} +
+ ) : ( +
+ {filteredBranches.map((branch) => { + const isCurrent = branch === currentBranch; + + return ( + + ); + })} +
+ )} +
+ + {/* Footer with actions */} + {isServiceRunning && ( +
+
+
+ ⚠️ +
+
+ Service is running +
+ Please stop {serviceName} before switching branches +
+
+
+ )} +
+
+ + ); +} diff --git a/web/src/components/ServiceCard/ServiceCard.tsx b/web/src/components/ServiceCard/ServiceCard.tsx index 3530cda..16ddabe 100644 --- a/web/src/components/ServiceCard/ServiceCard.tsx +++ b/web/src/components/ServiceCard/ServiceCard.tsx @@ -26,6 +26,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Service } from "@/types"; import { useState, useRef, useEffect } from "react"; +import { GitBranchSwitcher } from "@/components/GitBranchSwitcher/GitBranchSwitcher"; interface ServiceCardProps { service: Service; @@ -232,6 +233,18 @@ export function ServiceCard({ {service.description}

)} + + {/* Git Branch Switcher */} + {service.gitBranch && ( +
+ +
+ )} diff --git a/web/src/hooks/useServiceManagement.ts b/web/src/hooks/useServiceManagement.ts index fc584f6..002da83 100644 --- a/web/src/hooks/useServiceManagement.ts +++ b/web/src/hooks/useServiceManagement.ts @@ -42,6 +42,7 @@ export function useServiceManagement(onServiceUpdated: () => void) { isEnabled: true, buildSystem: "auto", verboseLogging: false, + gitBranch: "", envVars: {}, logs: [], uptime: "", diff --git a/web/src/types.ts b/web/src/types.ts index a4d5d49..5de281a 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -57,6 +57,7 @@ export interface Service { isEnabled: boolean; buildSystem: string; // "maven", "gradle", or "auto" verboseLogging: boolean; // Enable verbose/debug logging for build tools + gitBranch: string; // Current git branch (if service is a git repo) envVars: { [key: string]: EnvVar }; logs: LogEntry[]; // Resource monitoring fields