From 0afb80682a8c22bf7e2507577ee2cfbffaa40e89 Mon Sep 17 00:00:00 2001
From: Zachariah Ngonyani
Date: Tue, 2 Dec 2025 08:05:49 +0300
Subject: [PATCH 1/6] feat: implement git tag-based versioning and automated
releases
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implemented automatic version detection from git tags and automated
CI/CD release workflow:
Version Detection:
- Modified Makefile to auto-detect git tags as version
- Updated build.sh with same git tag detection logic
- Version detection priority: exact tag > git describe > "dev"
- Automatically strips 'v' prefix from tags (v1.0.0 → 1.0.0)
CI/CD Automation:
- Created GitHub Actions workflow (.github/workflows/release.yml)
- Triggers on git tag push (v*)
- Builds binaries for 5 platforms (Linux, macOS, Windows)
- Generates SHA256 checksums
- Auto-generates release notes from commits
- Publishes GitHub Release with all artifacts
Documentation:
- Added VERSION.md - comprehensive versioning guide
- Added RELEASE.md - step-by-step release process
- Includes CI/CD workflow documentation
Code Quality:
- Fixed octal notation in buildsystem.go (0111 → 0o111)
Usage:
make build # Auto-detects version from git tags
make build VERSION=x.y.z # Manual override
Release Process:
git tag -a v1.0.0 -m "Release version 1.0.0"
git push origin v1.0.0
# GitHub Actions handles the rest!
---
.github/workflows/release.yml | 261 +++++++++++--------------------
Makefile | 57 +++++++
RELEASE.md | 144 +++++++++++++++++
VERSION.md | 141 +++++++++++++++++
build.sh | 18 ++-
internal/services/buildsystem.go | 4 +-
6 files changed, 448 insertions(+), 177 deletions(-)
create mode 100644 Makefile
create mode 100644 RELEASE.md
create mode 100644 VERSION.md
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2c96bf5..4d404e3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,208 +1,121 @@
-name: Build and Release
+name: Release
on:
- pull_request:
- branches:
- - main
push:
tags:
- - "v*" # Triggers on version tags like v1.0.0
- workflow_dispatch:
+ - 'v*' # Triggers on version tags like v1.0.0, v2.1.3, etc.
permissions:
contents: write
jobs:
- build:
- name: Build Binaries
- runs-on: ${{ matrix.os }}
- strategy:
- matrix:
- include:
- - os: ubuntu-latest
- goos: linux
- goarch: amd64
- - os: ubuntu-latest
- goos: linux
- goarch: arm64
- - os: macos-latest
- goos: darwin
- goarch: amd64
- - os: macos-14
- goos: darwin
- goarch: arm64
- - os: ubuntu-latest
- goos: windows
- goarch: amd64
-
+ release:
+ name: Create Release
+ runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
-
- - name: Set up Go
- uses: actions/setup-go@v4
with:
- go-version: "1.23"
+ fetch-depth: 0
- - name: Set up Node.js
- uses: actions/setup-node@v4
+ - name: Set up Go
+ uses: actions/setup-go@v5
with:
- node-version: "18"
- cache: "yarn"
- cache-dependency-path: web/yarn.lock
+ go-version: '1.21'
- - name: Install frontend dependencies
+ - name: Get version from tag
+ id: version
run: |
- cd web
- yarn install --frozen-lockfile
+ # Remove 'v' prefix from tag
+ VERSION=${GITHUB_REF#refs/tags/v}
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- - name: Build frontend
+ - name: Build binaries
run: |
- cd web
- yarn build
+ # Build for multiple platforms
+ VERSION=${{ steps.version.outputs.version }}
+ COMMIT=$(git rev-parse --short HEAD)
+ DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+ LDFLAGS="-X main.version=$VERSION -X main.commit=$COMMIT -X main.date=$DATE"
- - name: Install cross-compilation tools
- run: |
- if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then
- sudo apt-get update
- if [ "${{ matrix.goos }}" = "windows" ]; then
- sudo apt-get install -y gcc-mingw-w64
- elif [ "${{ matrix.goos }}" = "linux" ] && [ "${{ matrix.goarch }}" = "arm64" ]; then
- sudo apt-get install -y gcc-aarch64-linux-gnu
- else
- sudo apt-get install -y gcc-multilib
- fi
- elif [[ "${{ matrix.os }}" == macos* ]]; then
- echo "Using native macOS compilation"
- fi
+ # Linux amd64
+ echo "Building for Linux amd64..."
+ GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o vertex-linux-amd64 .
- - name: Set up cross-compilation environment
- run: |
- if [ "${{ matrix.goos }}" = "windows" ]; then
- echo "CC=x86_64-w64-mingw32-gcc" >> $GITHUB_ENV
- elif [ "${{ matrix.goos }}" = "linux" ] && [ "${{ matrix.goarch }}" = "arm64" ]; then
- echo "CC=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
- fi
+ # Linux arm64
+ echo "Building for Linux arm64..."
+ GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o vertex-linux-arm64 .
- - name: Build binary
- env:
- GOOS: ${{ matrix.goos }}
- GOARCH: ${{ matrix.goarch }}
- CGO_ENABLED: 1
- run: |
- BINARY_NAME="vertex-${{ matrix.goos }}-${{ matrix.goarch }}"
- if [ "${{ matrix.goos }}" = "windows" ]; then
- BINARY_NAME="${BINARY_NAME}.exe"
- fi
- go build -ldflags="-s -w" -o "${BINARY_NAME}"
-
- if [[ "${{ matrix.os }}" == macos* ]]; then
- shasum -a 256 "${BINARY_NAME}" > "${BINARY_NAME}.sha256"
- else
- sha256sum "${BINARY_NAME}" > "${BINARY_NAME}.sha256"
- fi
-
- - name: Upload artifacts
- if: github.event_name != 'pull_request' # don't upload artifacts for PRs
- uses: actions/upload-artifact@v4
- with:
- name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
- path: |
- vertex-${{ matrix.goos }}-${{ matrix.goarch }}*
+ # macOS amd64
+ echo "Building for macOS amd64..."
+ GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o vertex-darwin-amd64 .
- docker:
- name: Build and Push Docker Images
- runs-on: ubuntu-latest
- if: startsWith(github.ref, 'refs/tags/')
- needs: build
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
+ # macOS arm64
+ echo "Building for macOS arm64..."
+ GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o vertex-darwin-arm64 .
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: "18"
- cache: "yarn"
- cache-dependency-path: web/yarn.lock
+ # Windows amd64
+ echo "Building for Windows amd64..."
+ GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o vertex-windows-amd64.exe .
- - name: Build frontend
+ - name: Create checksums
run: |
- cd web
- yarn install --frozen-lockfile
- yarn build
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Extract metadata
- id: meta
- uses: docker/metadata-action@v5
- with:
- images: ${{ secrets.DOCKERHUB_USERNAME }}/vertex
- tags: |
- type=ref,event=tag
- type=raw,value=latest
- type=raw,value={{version}}
-
- - name: Build and push Docker image
- uses: docker/build-push-action@v5
- with:
- context: .
- platforms: linux/amd64,linux/arm64
- push: true
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
- cache-from: type=gha
- cache-to: type=gha,mode=max
- build-args: |
- BUILDKIT_INLINE_CACHE=1
-
- release:
- name: Create Release
- needs: [build, docker]
- runs-on: ubuntu-latest
- if: startsWith(github.ref, 'refs/tags/')
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Download all artifacts
- uses: actions/download-artifact@v4
- with:
- pattern: binaries-*
- merge-multiple: true
-
- - name: List downloaded files
- run: |
- echo "Downloaded files:"
- find . -name "vertex-*" -type f
- find . -name "*.sha256" -type f
+ sha256sum vertex-* > checksums.txt
+ cat checksums.txt
- name: Generate release notes
+ id: release_notes
run: |
- echo "# Vertex Service Manager ${GITHUB_REF#refs/tags/}" > release-notes.md
- 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 "" >> 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
+ else
+ echo "## Initial Release" > release_notes.md
+ echo "" >> release_notes.md
+ echo "First release of Vertex Service Manager" >> release_notes.md
+ fi
+
+ echo "" >> release_notes.md
+ echo "## Installation" >> 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 "Make the binary executable and move it to your PATH:" >> release_notes.md
+ echo "\`\`\`bash" >> 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 "" >> release_notes.md
+ echo "Verify the installation:" >> release_notes.md
+ echo "\`\`\`bash" >> release_notes.md
+ echo "vertex version" >> release_notes.md
+ echo "\`\`\`" >> release_notes.md
+
+ cat release_notes.md
- name: Create GitHub Release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v1
with:
- files: |
- vertex-*
- body_path: release-notes.md
+ name: Release ${{ steps.version.outputs.tag }}
+ body_path: release_notes.md
draft: false
prerelease: false
+ files: |
+ vertex-linux-amd64
+ vertex-linux-arm64
+ vertex-darwin-amd64
+ vertex-darwin-arm64
+ vertex-windows-amd64.exe
+ checksums.txt
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ddf3334
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,57 @@
+.PHONY: build build-release version install clean 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: ## Build vertex with version information
+ @echo "Building Vertex $(VERSION) ($(COMMIT)) ..."
+ @go build -ldflags="$(LDFLAGS)" -o vertex .
+ @echo "✓ Build complete: ./vertex"
+
+build-release: ## 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 build artifacts
+ @rm -f vertex vertex-*
+ @echo "✓ 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/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)
}
From 9671e2a05df29d4a7c1e14227c8996c80b311892 Mon Sep 17 00:00:00 2001
From: Zachariah Ngonyani
Date: Tue, 2 Dec 2025 08:12:48 +0300
Subject: [PATCH 2/6] fix: add frontend build steps to release workflow
The release workflow was failing with "pattern dist/*: no matching files found"
because the Go build process embeds the frontend from web/dist/, but the
workflow wasn't building the frontend before building the Go binaries.
Added steps to:
- Set up Node.js 18 with Yarn cache
- Install frontend dependencies with yarn install --frozen-lockfile
- Build frontend with yarn build
This ensures web/dist/ exists before the Go build embeds it.
---
.github/workflows/release.yml | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4d404e3..b73a59f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -23,6 +23,23 @@ jobs:
with:
go-version: '1.21'
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ cache: 'yarn'
+ cache-dependency-path: web/yarn.lock
+
+ - name: Install frontend dependencies
+ run: |
+ cd web
+ yarn install --frozen-lockfile
+
+ - name: Build frontend
+ run: |
+ cd web
+ yarn build
+
- name: Get version from tag
id: version
run: |
From 14b045aa76885647f36eed9f258ee1e1aec43ab3 Mon Sep 17 00:00:00 2001
From: Zachariah Ngonyani
Date: Tue, 2 Dec 2025 08:20:49 +0300
Subject: [PATCH 3/6] fix: restore Docker build and add frontend build to
Makefile
Restored the original GitHub Actions workflow that was accidentally
simplified, which removed critical functionality:
GitHub Actions Workflow (.github/workflows/release.yml):
- Restored matrix build strategy for parallel platform builds
- Re-added Docker image build and push to Docker Hub
- Added workflow_dispatch trigger for manual runs
- Added pull_request trigger for testing
- Improved release notes with commit history
- Added Docker installation instructions to release notes
- Integrated git tag-based versioning into matrix builds
Makefile Enhancements:
- Added build-frontend target to build React web app
- Modified build and build-release to depend on build-frontend
- Added clean-all target to remove frontend artifacts
- Now ensures web/dist/ exists before Go build embeds it
Workflow Jobs:
1. build - Matrix build for all platforms with CGO support
2. docker - Build and push multi-arch Docker images
3. release - Create GitHub Release with binaries and notes
This ensures both binaries and Docker images are published on release.
---
.github/workflows/release.yml | 297 ++++++++++++++++++++++++----------
Makefile | 17 +-
2 files changed, 229 insertions(+), 85 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b73a59f..dd49ed2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,33 +1,54 @@
-name: Release
+name: Build and Release
on:
+ pull_request:
+ branches:
+ - main
push:
tags:
- - 'v*' # Triggers on version tags like v1.0.0, v2.1.3, etc.
+ - "v*" # Triggers on version tags like v1.0.0
+ workflow_dispatch:
permissions:
contents: write
jobs:
- release:
- name: Create Release
- runs-on: ubuntu-latest
+ build:
+ name: Build Binaries
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ include:
+ - os: ubuntu-latest
+ goos: linux
+ goarch: amd64
+ - os: ubuntu-latest
+ goos: linux
+ goarch: arm64
+ - os: macos-latest
+ goos: darwin
+ goarch: amd64
+ - os: macos-14
+ goos: darwin
+ goarch: arm64
+ - os: ubuntu-latest
+ goos: windows
+ goarch: amd64
+
steps:
- name: Checkout code
uses: actions/checkout@v4
- with:
- fetch-depth: 0
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v4
with:
- go-version: '1.21'
+ go-version: "1.23"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
- node-version: '18'
- cache: 'yarn'
+ node-version: "18"
+ cache: "yarn"
cache-dependency-path: web/yarn.lock
- name: Install frontend dependencies
@@ -40,99 +61,213 @@ jobs:
cd web
yarn build
+ - name: Install cross-compilation tools
+ run: |
+ if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then
+ sudo apt-get update
+ if [ "${{ matrix.goos }}" = "windows" ]; then
+ sudo apt-get install -y gcc-mingw-w64
+ elif [ "${{ matrix.goos }}" = "linux" ] && [ "${{ matrix.goarch }}" = "arm64" ]; then
+ sudo apt-get install -y gcc-aarch64-linux-gnu
+ else
+ sudo apt-get install -y gcc-multilib
+ fi
+ elif [[ "${{ matrix.os }}" == macos* ]]; then
+ echo "Using native macOS compilation"
+ fi
+
+ - name: Set up cross-compilation environment
+ run: |
+ if [ "${{ matrix.goos }}" = "windows" ]; then
+ echo "CC=x86_64-w64-mingw32-gcc" >> $GITHUB_ENV
+ elif [ "${{ matrix.goos }}" = "linux" ] && [ "${{ matrix.goarch }}" = "arm64" ]; then
+ echo "CC=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
+ fi
+
- name: Get version from tag
id: version
run: |
- # Remove 'v' prefix from tag
- VERSION=${GITHUB_REF#refs/tags/v}
+ # 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 "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
+ echo "commit=$COMMIT" >> $GITHUB_OUTPUT
+ echo "date=$DATE" >> $GITHUB_OUTPUT
- - name: Build binaries
+ - name: Build binary
+ env:
+ GOOS: ${{ matrix.goos }}
+ GOARCH: ${{ matrix.goarch }}
+ CGO_ENABLED: 1
run: |
- # Build for multiple platforms
- VERSION=${{ steps.version.outputs.version }}
- COMMIT=$(git rev-parse --short HEAD)
- DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
- LDFLAGS="-X main.version=$VERSION -X main.commit=$COMMIT -X main.date=$DATE"
+ BINARY_NAME="vertex-${{ matrix.goos }}-${{ matrix.goarch }}"
+ if [ "${{ matrix.goos }}" = "windows" ]; then
+ BINARY_NAME="${BINARY_NAME}.exe"
+ fi
+
+ 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"
+ else
+ sha256sum "${BINARY_NAME}" > "${BINARY_NAME}.sha256"
+ fi
+
+ - name: Upload artifacts
+ if: github.event_name != 'pull_request'
+ uses: actions/upload-artifact@v4
+ with:
+ name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
+ path: |
+ vertex-${{ matrix.goos }}-${{ matrix.goarch }}*
+
+ docker:
+ name: Build and Push Docker Images
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/')
+ needs: build
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
- # Linux amd64
- echo "Building for Linux amd64..."
- GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o vertex-linux-amd64 .
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "18"
+ cache: "yarn"
+ cache-dependency-path: web/yarn.lock
+
+ - name: Build frontend
+ run: |
+ cd web
+ yarn install --frozen-lockfile
+ yarn build
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ secrets.DOCKERHUB_USERNAME }}/vertex
+ tags: |
+ type=ref,event=tag
+ type=raw,value=latest
+ type=raw,value={{version}}
- # Linux arm64
- echo "Building for Linux arm64..."
- GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o vertex-linux-arm64 .
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ build-args: |
+ BUILDKIT_INLINE_CACHE=1
- # macOS amd64
- echo "Building for macOS amd64..."
- GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o vertex-darwin-amd64 .
+ release:
+ name: Create Release
+ needs: [build, docker]
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/')
- # macOS arm64
- echo "Building for macOS arm64..."
- GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o vertex-darwin-arm64 .
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
- # Windows amd64
- echo "Building for Windows amd64..."
- GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o vertex-windows-amd64.exe .
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: binaries-*
+ merge-multiple: true
- - name: Create checksums
+ - name: List downloaded files
run: |
- sha256sum vertex-* > checksums.txt
- cat checksums.txt
+ echo "Downloaded files:"
+ find . -name "vertex-*" -type f
+ find . -name "*.sha256" -type f
- name: Generate release notes
- id: release_notes
run: |
+ # 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
- else
- echo "## Initial Release" > release_notes.md
- echo "" >> release_notes.md
- echo "First release of Vertex Service Manager" >> release_notes.md
+ 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 "## Installation" >> 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 "Make the binary executable and move it to your PATH:" >> release_notes.md
- echo "\`\`\`bash" >> 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 "" >> release_notes.md
- echo "Verify the installation:" >> release_notes.md
- echo "\`\`\`bash" >> release_notes.md
- echo "vertex version" >> release_notes.md
- echo "\`\`\`" >> release_notes.md
-
- cat release_notes.md
+ 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@v1
+ uses: softprops/action-gh-release@v2
with:
- name: Release ${{ steps.version.outputs.tag }}
- body_path: release_notes.md
+ files: |
+ vertex-*
+ body_path: release-notes.md
draft: false
prerelease: false
- files: |
- vertex-linux-amd64
- vertex-linux-arm64
- vertex-darwin-amd64
- vertex-darwin-arm64
- vertex-windows-amd64.exe
- checksums.txt
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/Makefile b/Makefile
index ddf3334..cbf0a48 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build build-release version install clean help
+.PHONY: build build-release build-frontend version install clean clean-all help
# Version information
# Try to get version from git tag, fallback to dev
@@ -28,12 +28,17 @@ help: ## Show this help message
@echo 'Available targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
-build: ## Build vertex with version information
+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 release version (set VERSION=x.x.x)
+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"; \
@@ -49,9 +54,13 @@ version: build ## Build and show version information
install: build ## Build and install vertex
@./vertex install
-clean: ## Remove build artifacts
+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
From 28c604d60be8d1da3345afa73b381da9ad47cf68 Mon Sep 17 00:00:00 2001
From: Zachariah Ngonyani
Date: Thu, 4 Dec 2025 05:13:41 +0300
Subject: [PATCH 4/6] feat: add git branch switching for services
Implemented complete git branch management for services:
Backend (Go):
- Added GitBranch field to Service model
- Created git.go with helper functions:
* IsGitRepository() - Check if directory is a git repo
* GetCurrentBranch() - Get active branch
* GetBranches() - List all local branches
* GetRemoteBranches() - List remote branches
* HasUncommittedChanges() - Check for uncommitted work
* SwitchBranch() - Switch to different branch with safety checks
* GetGitInfo() - Comprehensive git repository information
- Added API endpoints:
* GET /api/services/{id}/git/info - Get git info
* GET /api/services/{id}/git/branches - List all branches
* POST /api/services/{id}/git/switch - Switch branch
- Manager methods: GetGitInfo(), GetGitBranches(), SwitchGitBranch()
- Auto-detect and update git branch on service load
Frontend (React/TypeScript):
- Created GitBranchSwitcher component with:
* Branch dropdown selector
* Current branch indicator
* Switch button with confirmation
* Safety check: prevents switching while service is running
* Loading states and error handling
- Integrated GitBranchSwitcher into ServiceCard
- Added gitBranch field to Service type
- Displays branch icon and current branch
- Lists all local and remote branches
Features:
- Automatic branch detection on startup
- Safety checks prevent switching with uncommitted changes
- Prevents branch switch while service is running
- Remote branch tracking (creates local branch if needed)
- Comprehensive error handling and user feedback
- Seamless integration with existing service management
---
internal/handlers/service_handler.go | 110 +++++++++
internal/models/service.go | 1 +
internal/services/database.go | 17 ++
internal/services/git.go | 218 ++++++++++++++++++
internal/services/manager.go | 120 ++++++++++
.../GitBranchSwitcher/GitBranchSwitcher.tsx | 191 +++++++++++++++
.../components/ServiceCard/ServiceCard.tsx | 13 ++
web/src/hooks/useServiceManagement.ts | 1 +
web/src/types.ts | 1 +
9 files changed, 672 insertions(+)
create mode 100644 internal/services/git.go
create mode 100644 web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx
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/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..57ab3e7 100644
--- a/internal/services/manager.go
+++ b/internal/services/manager.go
@@ -1000,3 +1000,123 @@ 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)
+ }
+
+ return GetGitInfo(service.Dir)
+}
+
+// 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)
+ }
+
+ if !IsGitRepository(service.Dir) {
+ return nil, fmt.Errorf("service is not a git repository")
+ }
+
+ // Get local branches
+ localBranches, err := GetBranches(service.Dir)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get remote branches
+ remoteBranches, err := GetRemoteBranches(service.Dir)
+ 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")
+ }
+
+ // Switch branch
+ if err := SwitchBranch(service.Dir, branch); err != nil {
+ return err
+ }
+
+ // Update the service's git branch info
+ currentBranch, err := GetCurrentBranch(service.Dir)
+ 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)
+ }
+
+ if !IsGitRepository(service.Dir) {
+ return nil
+ }
+
+ currentBranch, err := GetCurrentBranch(service.Dir)
+ 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..ff8ccee
--- /dev/null
+++ b/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx
@@ -0,0 +1,191 @@
+import { useState, useEffect } from "react";
+import { GitBranch, Check, Loader2 } from "lucide-react";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Button } from "@/components/ui/button";
+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 [selectedBranch, setSelectedBranch] = useState(currentBranch);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSwitching, setIsSwitching] = useState(false);
+ const { addToast } = useToast();
+
+ useEffect(() => {
+ setSelectedBranch(currentBranch);
+ }, [currentBranch]);
+
+ 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 () => {
+ if (selectedBranch === currentBranch) {
+ 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: selectedBranch }),
+ });
+
+ 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 '${selectedBranch}'`,
+ ),
+ );
+
+ // 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",
+ ),
+ );
+ setSelectedBranch(currentBranch); // Reset to current branch
+ } finally {
+ setIsSwitching(false);
+ }
+ };
+
+ // Don't render if no branches or not a git repo
+ if (branches.length === 0 && !isLoading) {
+ return null;
+ }
+
+ return (
+
+
+
+ {selectedBranch !== currentBranch && (
+
+ )}
+
+ );
+}
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
From 0887441aa6d5595d2c09b605375ad8a83274b28e Mon Sep 17 00:00:00 2001
From: Zachariah Ngonyani
Date: Thu, 4 Dec 2025 11:13:38 +0300
Subject: [PATCH 5/6] feat: replace git branch dropdown with searchable dialog
Improve git branch switching UX by replacing the fixed-width dropdown with a full modal dialog that includes search functionality. This provides better handling of long branch names and makes it easier to find specific branches.
Changes:
- Replace Select dropdown with Modal dialog component
- Add search input with real-time filtering of branches
- Display full branch names without wrapping using break-all
- Show filtered results count during search
- Improve visual hierarchy with larger clickable areas
- Add "Click to switch" hint for non-current branches
- Better warning display when service is running
- Fix backend git operations to use full paths via getServiceProjectsDirectory()
The dialog provides a much better user experience for repositories with many branches or long branch names.
---
internal/services/manager.go | 47 +++-
.../GitBranchSwitcher/GitBranchSwitcher.tsx | 215 +++++++++++++-----
2 files changed, 192 insertions(+), 70 deletions(-)
diff --git a/internal/services/manager.go b/internal/services/manager.go
index 57ab3e7..d897d69 100644
--- a/internal/services/manager.go
+++ b/internal/services/manager.go
@@ -1013,7 +1013,14 @@ func (sm *Manager) GetGitInfo(serviceUUID string) (*GitInfo, error) {
return nil, fmt.Errorf("service UUID %s not found", serviceUUID)
}
- return GetGitInfo(service.Dir)
+ // 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
@@ -1026,18 +1033,26 @@ func (sm *Manager) GetGitBranches(serviceUUID string) ([]string, error) {
return nil, fmt.Errorf("service UUID %s not found", serviceUUID)
}
- if !IsGitRepository(service.Dir) {
+ // 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(service.Dir)
+ localBranches, err := GetBranches(fullPath)
if err != nil {
return nil, err
}
// Get remote branches
- remoteBranches, err := GetRemoteBranches(service.Dir)
+ remoteBranches, err := GetRemoteBranches(fullPath)
if err != nil {
// If remote fetch fails, just return local branches
return localBranches, nil
@@ -1075,13 +1090,21 @@ func (sm *Manager) SwitchGitBranch(serviceUUID, branch string) error {
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(service.Dir, branch); err != nil {
+ if err := SwitchBranch(fullPath, branch); err != nil {
return err
}
// Update the service's git branch info
- currentBranch, err := GetCurrentBranch(service.Dir)
+ currentBranch, err := GetCurrentBranch(fullPath)
if err == nil {
sm.mutex.Lock()
service.GitBranch = currentBranch
@@ -1105,11 +1128,19 @@ func (sm *Manager) UpdateServiceGitBranch(serviceUUID string) error {
return fmt.Errorf("service UUID %s not found", serviceUUID)
}
- if !IsGitRepository(service.Dir) {
+ // 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(service.Dir)
+ currentBranch, err := GetCurrentBranch(fullPath)
if err != nil {
return err
}
diff --git a/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx b/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx
index ff8ccee..6df0d72 100644
--- a/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx
+++ b/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx
@@ -1,13 +1,8 @@
-import { useState, useEffect } from "react";
-import { GitBranch, Check, Loader2 } from "lucide-react";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
+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 {
@@ -24,15 +19,12 @@ export function GitBranchSwitcher({
isServiceRunning,
}: GitBranchSwitcherProps) {
const [branches, setBranches] = useState([]);
- const [selectedBranch, setSelectedBranch] = useState(currentBranch);
const [isLoading, setIsLoading] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
const { addToast } = useToast();
- useEffect(() => {
- setSelectedBranch(currentBranch);
- }, [currentBranch]);
-
useEffect(() => {
fetchBranches();
}, [serviceId]);
@@ -70,8 +62,9 @@ export function GitBranchSwitcher({
}
};
- const handleSwitchBranch = async () => {
- if (selectedBranch === currentBranch) {
+ const handleSwitchBranch = async (branch: string) => {
+ if (branch === currentBranch) {
+ setIsModalOpen(false);
return;
}
@@ -98,7 +91,7 @@ export function GitBranchSwitcher({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
- body: JSON.stringify({ branch: selectedBranch }),
+ body: JSON.stringify({ branch }),
});
if (!response.ok) {
@@ -110,10 +103,11 @@ export function GitBranchSwitcher({
addToast(
toast.success(
"Branch switched",
- `${serviceName} is now on branch '${selectedBranch}'`,
+ `${serviceName} is now on branch '${branch}'`,
),
);
+ setIsModalOpen(false);
// Refresh the page to update the service info
window.location.reload();
} catch (error) {
@@ -124,68 +118,165 @@ export function GitBranchSwitcher({
error instanceof Error ? error.message : "Unknown error",
),
);
- setSelectedBranch(currentBranch); // Reset to current branch
} 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 (
-
-
-