Git Repository

Stackyard

Stackyard scannt lokale Entwicklungsordner und bündelt Projekte, Git-Status, TODOs, Toolchains, Docker-Compose-Setups, Datei- und Launcher-Funktionen sowie Operations-Informationen in einer lokalen WebUI.

Projektseite ↗
HTTPShttps://zanvex.de/git/stackyard.git

internal/app/service.go

Zum Verzeichnis
package app

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"stackyard/internal/config"
	"stackyard/internal/git"
	"stackyard/internal/launcher"
	"stackyard/internal/project"
	"stackyard/internal/scanner"
	"stackyard/internal/storage"
	"stackyard/internal/todo"
)

type Service struct {
	roots     []config.RootConfig
	scanner   scanner.Scanner
	gitClient git.Client
	launcher  launcher.Service
	store     *storage.Store
}

func NewService(cfg config.Config, store *storage.Store) *Service {
	return &Service{
		roots:     cfg.Roots,
		scanner:   scanner.New(cfg.Scanner),
		gitClient: git.NewClient(),
		launcher:  launcher.New(cfg.Launchers),
		store:     store,
	}
}

func (s *Service) Refresh(ctx context.Context) ([]project.Project, error) {
	candidates, err := s.scanner.Scan(s.roots)
	if err != nil {
		return nil, err
	}
	if s.store != nil {
		rules, ruleErr := s.store.ListScannerRules(ctx)
		if ruleErr != nil {
			return nil, ruleErr
		}
		ignored := make(map[string]struct{})
		known := make(map[string]struct{}, len(candidates))
		for _, candidate := range candidates {
			known[filepath.Clean(candidate.Path)] = struct{}{}
		}
		for _, rule := range rules {
			clean := filepath.Clean(rule.Path)
			if rule.Kind == "ignore" {
				ignored[clean] = struct{}{}
				continue
			}
			if _, exists := known[clean]; exists {
				continue
			}
			info, statErr := os.Stat(clean)
			if statErr != nil || !info.IsDir() {
				continue
			}
			candidates = append(candidates, scanner.Candidate{RootName: "Manual", Path: clean, Name: filepath.Base(clean), PrimaryType: "manual", LastModified: info.ModTime()})
		}
		filtered := candidates[:0]
		for _, candidate := range candidates {
			if _, skip := ignored[filepath.Clean(candidate.Path)]; !skip {
				filtered = append(filtered, candidate)
			}
		}
		candidates = filtered
	}
	existingByPath := map[string]project.Project{}
	if s.store != nil {
		if existing, err := s.store.ListProjects(ctx); err == nil {
			for _, p := range existing {
				existingByPath[p.Path] = p
			}
		}
	}

	projects := make([]project.Project, 0, len(candidates))
	scannedAt := time.Now()
	for _, candidate := range candidates {
		p := project.Project{
			ID:            project.IDFromPath(candidate.Path),
			Name:          candidate.Name,
			RootName:      candidate.RootName,
			Path:          candidate.Path,
			PrimaryType:   candidate.PrimaryType,
			State:         project.StateActive,
			Markers:       markerNames(candidate.Markers),
			LastModified:  candidate.LastModified,
			LastScannedAt: scannedAt,
		}
		if existing, ok := existingByPath[candidate.Path]; ok {
			p.State = existing.State
			p.Favorite = existing.Favorite
			p.Description = existing.Description
			p.Notes = existing.Notes
			p.Labels = existing.Labels
			p.LastOpenedAt = existing.LastOpenedAt
		}
		p.MiseTools = project.DiscoverMiseTools(candidate.Path)
		p.ComposeFiles = project.DiscoverComposeFiles(candidate.Path)
		p.Ports = project.DiscoverPorts(candidate.Path)

		status, commit, err := s.gitClient.Inspect(ctx, candidate.Path)
		if err != nil {
			fmt.Fprintf(os.Stderr, "stackyard: git inspect skipped for %s: %v\n", candidate.Path, err)
		} else {
			p.Git = project.GitInfo{
				IsRepo: status.IsRepo,
				Branch: status.Branch,
				Dirty:  status.Dirty,
				Ahead:  status.Ahead,
				Behind: status.Behind,
			}
			if commit != nil {
				p.Git.LastCommitHash = commit.Hash
				p.Git.LastCommitSubject = commit.Subject
				p.Git.LastCommitAt = commit.Committed
			}
		}

		todoPath, exists, err := todo.Resolve(candidate.Path)
		if err != nil {
			fmt.Fprintf(os.Stderr, "stackyard: todo resolve skipped for %s: %v\n", candidate.Path, err)
		} else if exists {
			p.TodoPath = todoPath
		}

		readmePath := filepath.Join(candidate.Path, "README.md")
		if _, err := os.Stat(readmePath); err == nil {
			p.ReadmePath = readmePath
		}

		projects = append(projects, p)
	}

	if err := s.store.UpsertProjects(ctx, projects); err != nil {
		return nil, err
	}
	return projects, nil
}

func (s *Service) PreviewScan(ctx context.Context) ([]scanner.Candidate, error) {
	candidates, err := s.scanner.Scan(s.roots)
	if err != nil {
		return nil, err
	}
	if s.store == nil {
		return candidates, nil
	}
	rules, err := s.store.ListScannerRules(ctx)
	if err != nil {
		return nil, err
	}
	ignored := make(map[string]struct{})
	known := make(map[string]struct{}, len(candidates))
	for _, candidate := range candidates {
		known[filepath.Clean(candidate.Path)] = struct{}{}
	}
	for _, rule := range rules {
		clean := filepath.Clean(rule.Path)
		if rule.Kind == "ignore" {
			ignored[clean] = struct{}{}
			continue
		}
		if _, exists := known[clean]; exists {
			continue
		}
		info, statErr := os.Stat(clean)
		if statErr == nil && info.IsDir() {
			candidates = append(candidates, scanner.Candidate{RootName: "Manual", Path: clean, Name: filepath.Base(clean), PrimaryType: "manual", LastModified: info.ModTime()})
		}
	}
	filtered := candidates[:0]
	for _, candidate := range candidates {
		if _, skip := ignored[filepath.Clean(candidate.Path)]; !skip {
			filtered = append(filtered, candidate)
		}
	}
	return filtered, nil
}

func (s *Service) List(ctx context.Context) ([]project.Project, error) {
	if s.store != nil {
		projects, err := s.store.ListProjects(ctx)
		if err != nil {
			return nil, err
		}
		if len(projects) > 0 {
			return projects, nil
		}
	}
	return s.Refresh(ctx)
}

func (s *Service) Get(ctx context.Context, id string) (project.Project, bool, error) {
	if s.store != nil {
		p, ok, err := s.store.GetProject(ctx, id)
		if err != nil {
			return project.Project{}, false, err
		}
		if ok {
			return p, true, nil
		}
	}

	projects, err := s.Refresh(ctx)
	if err != nil {
		return project.Project{}, false, err
	}
	for _, p := range projects {
		if p.ID == id {
			return p, true, nil
		}
	}
	return project.Project{}, false, nil
}

func (s *Service) CheckoutBranch(ctx context.Context, id, branch string) error {
	p, ok, err := s.Get(ctx, id)
	if err != nil {
		return err
	}
	if !ok {
		return errors.New("project not found")
	}
	if !p.Git.IsRepo {
		return errors.New("project is not a git repository")
	}

	branches, _, err := s.gitClient.Branches(ctx, p.Path)
	if err != nil {
		return err
	}
	found := false
	for _, existing := range branches {
		if existing == branch {
			found = true
			break
		}
	}
	if !found {
		return errors.New("branch does not exist")
	}

	if err := s.gitClient.CheckoutBranch(ctx, p.Path, branch); err != nil {
		return err
	}
	_, err = s.Refresh(ctx)
	return err
}

func (s *Service) CreateBranch(ctx context.Context, id, branch string) error {
	p, ok, err := s.Get(ctx, id)
	if err != nil {
		return err
	}
	if !ok {
		return errors.New("project not found")
	}
	if !p.Git.IsRepo {
		return errors.New("project is not a git repository")
	}

	branches, _, err := s.gitClient.Branches(ctx, p.Path)
	if err != nil {
		return err
	}
	for _, existing := range branches {
		if existing == branch {
			return errors.New("branch already exists")
		}
	}

	if err := s.gitClient.CreateBranch(ctx, p.Path, branch); err != nil {
		return err
	}
	_, err = s.Refresh(ctx)
	return err
}

func (s *Service) Launch(ctx context.Context, id string, action launcher.Action) error {
	p, ok, err := s.Get(ctx, id)
	if err != nil {
		return err
	}
	if !ok {
		return errors.New("project not found")
	}
	if err := s.launcher.Open(action, p.Path); err != nil {
		return err
	}
	if s.store != nil {
		return s.store.TouchProjectOpened(ctx, p.ID)
	}
	return nil
}

func (s *Service) SetFavorite(ctx context.Context, id string, favorite bool) error {
	p, ok, err := s.Get(ctx, id)
	if err != nil {
		return err
	}
	if !ok {
		return errors.New("project not found")
	}
	if s.store == nil {
		return errors.New("storage unavailable")
	}
	if err := s.store.SetFavorite(ctx, p.ID, favorite); err != nil {
		return err
	}
	return nil
}

func (s *Service) SetState(ctx context.Context, id string, state project.State) error {
	p, ok, err := s.Get(ctx, id)
	if err != nil {
		return err
	}
	if !ok {
		return errors.New("project not found")
	}
	switch state {
	case project.StateActive, project.StatePaused, project.StateArchived, project.StateExperimental:
	default:
		return errors.New("invalid project state")
	}
	if s.store == nil {
		return errors.New("storage unavailable")
	}
	if err := s.store.SetState(ctx, p.ID, state); err != nil {
		return err
	}
	return nil
}

func markerNames(markers []scanner.Marker) []string {
	names := make([]string, 0, len(markers))
	for _, marker := range markers {
		names = append(names, marker.File)
	}
	return names
}