a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
563 lines
19 KiB
Go
563 lines
19 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/zzet/gortex/internal/llm"
|
|
"github.com/zzet/gortex/internal/pathkey"
|
|
"github.com/zzet/gortex/internal/platform"
|
|
)
|
|
|
|
var (
|
|
// globalConfigMu serialises Save() so concurrent writers can't
|
|
// truncate each other's payload.
|
|
globalConfigMu sync.Mutex
|
|
|
|
// projectNameRe matches valid project names: alphanumeric, hyphens, underscores.
|
|
projectNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
|
)
|
|
|
|
// RepoEntry defines a repository in the config.
|
|
type RepoEntry struct {
|
|
Path string `mapstructure:"path" yaml:"path"`
|
|
Name string `mapstructure:"name" yaml:"name,omitempty"`
|
|
Ref string `mapstructure:"ref" yaml:"ref,omitempty"`
|
|
// Exclude adds repo-specific ignore patterns layered on top of the
|
|
// global Exclude list (gitignore semantics).
|
|
Exclude []string `mapstructure:"exclude" yaml:"exclude,omitempty"`
|
|
// Workspace is an optional override for the workspace slug, set in
|
|
// the user's global config when the repo itself has no
|
|
// `.gortex.yaml::workspace` (or when the user wants to override
|
|
// one — e.g. tracking an OSS repo without leaving an artifact in
|
|
// it). Wins over `.gortex.yaml::workspace`. Falls through to the
|
|
// default (repo prefix) when both are empty.
|
|
Workspace string `mapstructure:"workspace" yaml:"workspace,omitempty"`
|
|
// Project is the matching override for the project slug. Same
|
|
// precedence rules as Workspace.
|
|
Project string `mapstructure:"project" yaml:"project,omitempty"`
|
|
// AsWorktree is a transient track-time directive (the `--as-worktree`
|
|
// flag), never persisted: when set, a linked git worktree of an
|
|
// already-tracked repo is registered as an INDEPENDENT instance under
|
|
// a derived `<base>@<tag>` prefix instead of coalescing into the
|
|
// canonical checkout. The decision is recorded by persisting the
|
|
// derived prefix into Name, so this flag does not need to round-trip.
|
|
AsWorktree bool `mapstructure:"-" yaml:"-"`
|
|
// Force is a transient track-time directive (never persisted): when set it
|
|
// overrides the home / filesystem-root safety guard so a deliberately
|
|
// chosen root location is tracked anyway.
|
|
Force bool `mapstructure:"-" yaml:"-"`
|
|
}
|
|
|
|
// ProjectConfig defines a named project grouping repos.
|
|
type ProjectConfig struct {
|
|
Repos []RepoEntry `mapstructure:"repos" yaml:"repos"`
|
|
}
|
|
|
|
// GlobalConfig is the user-level config at ~/.gortex/config.yaml.
|
|
type GlobalConfig struct {
|
|
Projects map[string]ProjectConfig `mapstructure:"projects" yaml:"projects,omitempty"`
|
|
Repos []RepoEntry `mapstructure:"repos" yaml:"repos,omitempty"`
|
|
ActiveProject string `mapstructure:"active_project" yaml:"active_project,omitempty"`
|
|
// Exclude is the user-level ignore list layered above the builtin
|
|
// baseline and below per-RepoEntry / workspace lists.
|
|
Exclude []string `mapstructure:"exclude" yaml:"exclude,omitempty"`
|
|
|
|
// LLM is the user-level local-LLM service config (`llm.model:` etc.).
|
|
// Merged into the repo-local Config.LLM at daemon startup via
|
|
// MergeLLMInto — local non-zero fields win, global fills the rest.
|
|
// Lets users keep model paths and tuning in one place across repos
|
|
// without duplicating an `llm:` block in every `.gortex.yaml`.
|
|
LLM llm.Config `mapstructure:"llm" yaml:"llm,omitempty"`
|
|
|
|
// Embedding is the user-level semantic-search config (`embedding.provider:`
|
|
// etc.). Merged into the repo-local Config.Embedding at daemon startup via
|
|
// MergeEmbeddingInto — local non-zero fields win, global fills the rest —
|
|
// so an `embedding:` block can live in ~/.gortex/config.yaml and apply
|
|
// across repos instead of being duplicated in every `.gortex.yaml`.
|
|
Embedding EmbeddingConfig `mapstructure:"embedding" yaml:"embedding,omitempty"`
|
|
|
|
// Daemon carries policy for the long-running daemon process itself —
|
|
// settings that govern the shared background service rather than any
|
|
// single workspace. Lives in the user-level config for that reason.
|
|
Daemon DaemonConfig `mapstructure:"daemon" yaml:"daemon,omitempty"`
|
|
|
|
// configPath stores the file path used for Save(). Set by LoadGlobal or SetConfigPath.
|
|
configPath string `yaml:"-"`
|
|
}
|
|
|
|
// DaemonConfig is the `daemon:` block in ~/.gortex/config.yaml.
|
|
type DaemonConfig struct {
|
|
// MemoryLimit is the standing soft memory limit (the Go runtime's
|
|
// SetMemoryLimit) applied at daemon boot, written as a human size —
|
|
// "4GiB", "2048MiB", "2G" — or "off" / "0" to disable it. Empty
|
|
// applies the built-in default policy (a fraction of host RAM, clamped
|
|
// to a sane band). The GORTEX_DAEMON_MEMLIMIT env var overrides this,
|
|
// and a runtime-honored GOMEMLIMIT overrides both.
|
|
MemoryLimit string `mapstructure:"memory_limit" yaml:"memory_limit,omitempty"`
|
|
}
|
|
|
|
// MergeLLMInto layers a repo-local llm.Config over the global user
|
|
// config: each zero-valued field of local is filled from gc.LLM,
|
|
// per provider sub-block. Local non-zero values always win — including
|
|
// an explicit per-repo override of an inherited global model path.
|
|
// Safe to call on a nil receiver (returns local unchanged), so daemon
|
|
// startup paths don't need separate nil-checks for the global config.
|
|
//
|
|
// The local provider's model path additionally gets `~/` expanded
|
|
// against $HOME so users can write portable paths in either config.
|
|
func (gc *GlobalConfig) MergeLLMInto(local llm.Config) llm.Config {
|
|
if gc != nil {
|
|
local = local.MergedWith(gc.LLM)
|
|
}
|
|
local.Local.Model = expandHome(local.Local.Model)
|
|
return local
|
|
}
|
|
|
|
// MergeEmbeddingInto layers a repo-local EmbeddingConfig over the global user
|
|
// config: each zero-valued field of local is filled from gc.Embedding, so an
|
|
// `embedding:` block in ~/.gortex/config.yaml applies across repos while any
|
|
// per-repo `.gortex.yaml` value still wins. Enabled is a tri-state pointer, so
|
|
// a local nil (unset) inherits the global toggle. Safe on a nil receiver.
|
|
func (gc *GlobalConfig) MergeEmbeddingInto(local EmbeddingConfig) EmbeddingConfig {
|
|
if gc == nil {
|
|
return local
|
|
}
|
|
g := gc.Embedding
|
|
if local.Enabled == nil {
|
|
local.Enabled = g.Enabled
|
|
}
|
|
if local.Provider == "" {
|
|
local.Provider = g.Provider
|
|
}
|
|
if local.APIURL == "" {
|
|
local.APIURL = g.APIURL
|
|
}
|
|
if local.APIModel == "" {
|
|
local.APIModel = g.APIModel
|
|
}
|
|
if local.MaxSymbols == 0 {
|
|
local.MaxSymbols = g.MaxSymbols
|
|
}
|
|
if local.ChunkThresholdLines == 0 {
|
|
local.ChunkThresholdLines = g.ChunkThresholdLines
|
|
}
|
|
if local.ChunkWindowLines == 0 {
|
|
local.ChunkWindowLines = g.ChunkWindowLines
|
|
}
|
|
if local.APIConcurrency == 0 {
|
|
local.APIConcurrency = g.APIConcurrency
|
|
}
|
|
if local.Variant == "" {
|
|
local.Variant = g.Variant
|
|
}
|
|
return local
|
|
}
|
|
|
|
// knownGlobalTopLevelKeys is the set of top-level keys LoadGlobal understands.
|
|
// Anything else in ~/.gortex/config.yaml is silently dropped by yaml.Unmarshal,
|
|
// so UnknownGlobalKeys surfaces it for a startup warning.
|
|
var knownGlobalTopLevelKeys = map[string]bool{
|
|
"projects": true, "repos": true, "active_project": true,
|
|
"exclude": true, "llm": true, "embedding": true,
|
|
}
|
|
|
|
// UnknownGlobalKeys returns the top-level keys present in the global config file
|
|
// that gortex does not recognise — e.g. an `embedding:` block placed under the
|
|
// wrong nesting, or a typo. It never fails: a missing or unparseable file yields
|
|
// no keys, so forward compatibility (an older binary reading a newer config) is
|
|
// preserved. Pass a path to override the default ~/.gortex/config.yaml.
|
|
func UnknownGlobalKeys(configPath ...string) []string {
|
|
path := DefaultGlobalConfigPath()
|
|
if len(configPath) > 0 && configPath[0] != "" {
|
|
path = configPath[0]
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var top map[string]any
|
|
if err := yaml.Unmarshal(data, &top); err != nil {
|
|
return nil
|
|
}
|
|
var unknown []string
|
|
for k := range top {
|
|
if !knownGlobalTopLevelKeys[k] {
|
|
unknown = append(unknown, k)
|
|
}
|
|
}
|
|
sort.Strings(unknown)
|
|
return unknown
|
|
}
|
|
|
|
// expandHome resolves a leading `~/` in a path against $HOME so users
|
|
// can write portable model paths in their global config. No-op when
|
|
// the path is empty, absolute without `~`, or `~` is not the first
|
|
// character. Returns the input unchanged on any os.UserHomeDir error.
|
|
func expandHome(p string) string {
|
|
if p == "" || !strings.HasPrefix(p, "~") {
|
|
return p
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return p
|
|
}
|
|
if p == "~" {
|
|
return home
|
|
}
|
|
if strings.HasPrefix(p, "~/") {
|
|
return filepath.Join(home, p[2:])
|
|
}
|
|
return p
|
|
}
|
|
|
|
// DefaultGlobalConfigPath returns the default path: ~/.gortex/config.yaml,
|
|
// or the $XDG_CONFIG_HOME equivalent when that variable is set.
|
|
//
|
|
// Resolved fresh on every call so HOME / XDG_CONFIG_HOME changes (notably
|
|
// t.Setenv in tests) take effect. A previous version cached this with
|
|
// sync.Once, which made the first caller win for the lifetime of the
|
|
// process — any subsequent test that flipped HOME silently ended up
|
|
// writing into the developer's real config.
|
|
func DefaultGlobalConfigPath() string {
|
|
return filepath.Join(platform.ConfigDir(), "config.yaml")
|
|
}
|
|
|
|
// LoadGlobal reads the global config from ~/.gortex/config.yaml.
|
|
// If the file does not exist, it returns an empty GlobalConfig (no error).
|
|
// If configPath is empty, the default path is used.
|
|
func LoadGlobal(configPath ...string) (*GlobalConfig, error) {
|
|
path := DefaultGlobalConfigPath()
|
|
if len(configPath) > 0 && configPath[0] != "" {
|
|
path = configPath[0]
|
|
}
|
|
|
|
gc := &GlobalConfig{
|
|
configPath: path,
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
// Absent GlobalConfig is not an error — return empty struct.
|
|
return gc, nil
|
|
}
|
|
return nil, fmt.Errorf("reading global config: %w", err)
|
|
}
|
|
|
|
if err := yaml.Unmarshal(data, gc); err != nil {
|
|
return nil, fmt.Errorf("parsing global config: %w", err)
|
|
}
|
|
|
|
gc.configPath = path
|
|
return gc, nil
|
|
}
|
|
|
|
// SetConfigPath overrides the file path used by Save().
|
|
func (gc *GlobalConfig) SetConfigPath(path string) {
|
|
gc.configPath = path
|
|
}
|
|
|
|
// ConfigPath returns the file path used by Save().
|
|
func (gc *GlobalConfig) ConfigPath() string {
|
|
if gc.configPath == "" {
|
|
return DefaultGlobalConfigPath()
|
|
}
|
|
return gc.configPath
|
|
}
|
|
|
|
// Save writes the GlobalConfig to disk, creating the directory if needed.
|
|
// Uses a file-level mutex to prevent concurrent writes.
|
|
func (gc *GlobalConfig) Save() error {
|
|
globalConfigMu.Lock()
|
|
defer globalConfigMu.Unlock()
|
|
|
|
path := gc.ConfigPath()
|
|
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("creating config directory %s: %w", dir, err)
|
|
}
|
|
|
|
data, err := yaml.Marshal(gc)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling global config: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
|
return fmt.Errorf("writing global config to %s: %w", path, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Validate checks the GlobalConfig for:
|
|
// - Duplicate Repo_Prefix values across top-level repos and all projects
|
|
// - Invalid project name format
|
|
// - Conflicting name overrides for shared repos across projects
|
|
func (gc *GlobalConfig) Validate() error {
|
|
var errs []string
|
|
|
|
// Check project name format.
|
|
for name := range gc.Projects {
|
|
if !projectNameRe.MatchString(name) {
|
|
errs = append(errs, fmt.Sprintf(
|
|
"invalid project name %q: must contain only alphanumeric characters, hyphens, or underscores", name))
|
|
}
|
|
}
|
|
|
|
// Check duplicate prefixes across top-level repos.
|
|
if dupErrs := checkDuplicatePrefixes(gc.Repos); len(dupErrs) > 0 {
|
|
errs = append(errs, dupErrs...)
|
|
}
|
|
|
|
// Check duplicate prefixes within each project.
|
|
for projName, proj := range gc.Projects {
|
|
if dupErrs := checkDuplicatePrefixes(proj.Repos); len(dupErrs) > 0 {
|
|
for _, e := range dupErrs {
|
|
errs = append(errs, fmt.Sprintf("project %q: %s", projName, e))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check conflicting name overrides for shared repos across projects.
|
|
if conflictErrs := checkConflictingNameOverrides(gc.Projects); len(conflictErrs) > 0 {
|
|
errs = append(errs, conflictErrs...)
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("config validation failed:\n - %s", strings.Join(errs, "\n - "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResolvePrefix returns the effective Repo_Prefix for a RepoEntry.
|
|
// If Name is set, it is used directly. Otherwise, the prefix is derived
|
|
// from the last path component.
|
|
func ResolvePrefix(entry RepoEntry) string {
|
|
if entry.Name != "" {
|
|
return entry.Name
|
|
}
|
|
return filepath.Base(entry.Path)
|
|
}
|
|
|
|
// FindRepoByPrefix searches top-level Repos and all Projects for an entry
|
|
// whose ResolvePrefix matches. Returns nil when no entry matches.
|
|
func (gc *GlobalConfig) FindRepoByPrefix(prefix string) *RepoEntry {
|
|
if gc == nil {
|
|
return nil
|
|
}
|
|
for i := range gc.Repos {
|
|
if ResolvePrefix(gc.Repos[i]) == prefix {
|
|
return &gc.Repos[i]
|
|
}
|
|
}
|
|
for _, proj := range gc.Projects {
|
|
for i := range proj.Repos {
|
|
if ResolvePrefix(proj.Repos[i]) == prefix {
|
|
return &proj.Repos[i]
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkDuplicatePrefixes returns errors for any duplicate Repo_Prefix values.
|
|
func checkDuplicatePrefixes(entries []RepoEntry) []string {
|
|
seen := make(map[string]string) // prefix → first path
|
|
var errs []string
|
|
for _, e := range entries {
|
|
prefix := ResolvePrefix(e)
|
|
if firstPath, ok := seen[prefix]; ok {
|
|
errs = append(errs, fmt.Sprintf(
|
|
"duplicate repo prefix %q: %s and %s", prefix, firstPath, e.Path))
|
|
} else {
|
|
seen[prefix] = e.Path
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
// checkConflictingNameOverrides checks that shared repos (same absolute path)
|
|
// across different projects don't have conflicting name overrides.
|
|
func checkConflictingNameOverrides(projects map[string]ProjectConfig) []string {
|
|
// Map: absolute path → map[project name] → name override
|
|
type nameInfo struct {
|
|
project string
|
|
name string
|
|
}
|
|
pathNames := make(map[string][]nameInfo)
|
|
|
|
for projName, proj := range projects {
|
|
for _, entry := range proj.Repos {
|
|
absPath := normalizePath(entry.Path)
|
|
pathNames[absPath] = append(pathNames[absPath], nameInfo{
|
|
project: projName,
|
|
name: ResolvePrefix(entry),
|
|
})
|
|
}
|
|
}
|
|
|
|
var errs []string
|
|
for absPath, infos := range pathNames {
|
|
if len(infos) < 2 {
|
|
continue
|
|
}
|
|
// Check if all resolved names are the same.
|
|
firstName := infos[0].name
|
|
for _, info := range infos[1:] {
|
|
if info.name != firstName {
|
|
errs = append(errs, fmt.Sprintf(
|
|
"conflicting name overrides for repo %s: project %q uses %q, project %q uses %q",
|
|
absPath, infos[0].project, firstName, info.project, info.name))
|
|
}
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
// normalizePath attempts to resolve a path to absolute for comparison.
|
|
// If resolution fails, the original path is returned.
|
|
func normalizePath(p string) string {
|
|
abs, err := filepath.Abs(p)
|
|
if err != nil {
|
|
return p
|
|
}
|
|
return abs
|
|
}
|
|
|
|
// AddRepo adds a repository entry to the top-level repos list.
|
|
// Relative paths are resolved to absolute. Duplicate paths are skipped.
|
|
func (gc *GlobalConfig) AddRepo(entry RepoEntry) error {
|
|
absPath, err := filepath.Abs(entry.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("resolving path %s: %w", entry.Path, err)
|
|
}
|
|
entry.Path = absPath
|
|
|
|
// Check for duplicate path. Compare on folded identity so a
|
|
// case-only or Unicode-normalisation variant of an already-tracked
|
|
// directory on a case-insensitive filesystem is recognised as the
|
|
// same repo. The existing entry's stored Path spelling is preserved
|
|
// as the identity anchor — we never append a second entry for it.
|
|
for _, existing := range gc.Repos {
|
|
existingAbs := normalizePath(existing.Path)
|
|
if pathkey.SamePathIdentity(existingAbs, absPath) {
|
|
return nil // already tracked, skip
|
|
}
|
|
}
|
|
|
|
gc.Repos = append(gc.Repos, entry)
|
|
return nil
|
|
}
|
|
|
|
// RemoveRepo removes a repository entry from the top-level repos list by path.
|
|
// The path is resolved to absolute for comparison.
|
|
func (gc *GlobalConfig) RemoveRepo(path string) error {
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return fmt.Errorf("resolving path %s: %w", path, err)
|
|
}
|
|
|
|
for i, entry := range gc.Repos {
|
|
entryAbs := normalizePath(entry.Path)
|
|
if pathkey.SamePathIdentity(entryAbs, absPath) {
|
|
gc.Repos = append(gc.Repos[:i], gc.Repos[i+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("repository not found: %s", path)
|
|
}
|
|
|
|
// DedupeRepos removes tracked-repo entries that name the same directory as
|
|
// an earlier entry but differ only in path spelling — letter case or
|
|
// Unicode normalisation — on a case-insensitive filesystem. It is the
|
|
// startup-healing pass for configs that already accumulated a duplicate
|
|
// (issue #270): before folding was applied, two casings of one directory
|
|
// could each become a tracked entry, flipping the daemon into multi-repo
|
|
// mode and desyncing the graph.
|
|
//
|
|
// The FIRST entry for each directory is kept: it is the oldest and the one
|
|
// whose repo prefix most likely already owns the indexed graph. Later
|
|
// duplicates are returned so the caller can log which spelling was dropped
|
|
// and persist the cleaned list. A surviving entry's stored Path is never
|
|
// rewritten — its spelling is identity-bearing.
|
|
func (gc *GlobalConfig) DedupeRepos() (removed []RepoEntry) {
|
|
if len(gc.Repos) < 2 {
|
|
return nil
|
|
}
|
|
kept := make([]RepoEntry, 0, len(gc.Repos))
|
|
for _, entry := range gc.Repos {
|
|
entryAbs := normalizePath(entry.Path)
|
|
dup := false
|
|
for _, k := range kept {
|
|
if pathkey.SamePathIdentity(normalizePath(k.Path), entryAbs) {
|
|
dup = true
|
|
break
|
|
}
|
|
}
|
|
if dup {
|
|
removed = append(removed, entry)
|
|
continue
|
|
}
|
|
kept = append(kept, entry)
|
|
}
|
|
if len(removed) == 0 {
|
|
return nil
|
|
}
|
|
gc.Repos = kept
|
|
return removed
|
|
}
|
|
|
|
// ResolveRepos returns the effective repo list for a given project name.
|
|
// If projectName is empty, it returns the top-level repos list.
|
|
// Otherwise it tries two complementary resolution strategies in order:
|
|
//
|
|
// 1. Top-level Projects map: gc.Projects[projectName].Repos. This is the
|
|
// "explicit project definition" form.
|
|
// 2. Per-repo annotations: every gc.Repos[i] whose Project field equals
|
|
// projectName. This is the "flat repos list with project: <slug> tags"
|
|
// form, which is what users get when they run `gortex track --project`.
|
|
//
|
|
// Strategy 1 wins when both are populated for the same name, preserving
|
|
// the existing precedence so users who define a project explicitly are
|
|
// not surprised by per-repo annotations bleeding in.
|
|
//
|
|
// Returns the "project not found" error only when both strategies yield
|
|
// zero entries.
|
|
func (gc *GlobalConfig) ResolveRepos(projectName string) ([]RepoEntry, error) {
|
|
if projectName == "" {
|
|
return gc.Repos, nil
|
|
}
|
|
|
|
if proj, ok := gc.Projects[projectName]; ok {
|
|
return proj.Repos, nil
|
|
}
|
|
|
|
// Fall back to per-repo Project annotations on the flat repos list.
|
|
var tagged []RepoEntry
|
|
for _, entry := range gc.Repos {
|
|
if entry.Project == projectName {
|
|
tagged = append(tagged, entry)
|
|
}
|
|
}
|
|
if len(tagged) > 0 {
|
|
return tagged, nil
|
|
}
|
|
|
|
available := make([]string, 0, len(gc.Projects))
|
|
for name := range gc.Projects {
|
|
available = append(available, name)
|
|
}
|
|
return nil, fmt.Errorf("project not found: %q (available: %s)",
|
|
projectName, strings.Join(available, ", "))
|
|
}
|