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.
HTTPS
https://zanvex.de/git/stackyard.gitinternal/app/service.go
Zum Verzeichnispackage 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
}