chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/zzet/gortex/internal/config"
|
||||
"github.com/zzet/gortex/internal/daemon"
|
||||
"github.com/zzet/gortex/internal/excludes"
|
||||
)
|
||||
|
||||
// Flags are shared by add/remove. Cobra parses them once per command,
|
||||
// so declaring package-level vars is fine here.
|
||||
var (
|
||||
excludeGlobalFlag bool
|
||||
excludeRepoFlag string
|
||||
)
|
||||
|
||||
var configCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage Gortex configuration",
|
||||
}
|
||||
|
||||
var configExcludeCmd = &cobra.Command{
|
||||
Use: "exclude",
|
||||
Short: "Manage the effective ignore list (indexing + watching)",
|
||||
Long: `Add, list, or remove patterns from the effective ignore list used by both
|
||||
indexing and watching. Patterns follow .gitignore semantics.
|
||||
|
||||
Targets (in precedence order; later layers override earlier):
|
||||
1. Builtin baseline (read-only)
|
||||
2. Global - ~/.gortex/config.yaml (--global)
|
||||
3. Repo - GlobalConfig.repos[].exclude (--repo <name>)
|
||||
4. Workspace - ./.gortex.yaml at the repo root (default)
|
||||
|
||||
Use "!pattern" in a later layer to re-include something an earlier layer
|
||||
excluded.`,
|
||||
}
|
||||
|
||||
var configExcludeAddCmd = &cobra.Command{
|
||||
Use: "add <path-or-pattern>",
|
||||
Short: "Append a pattern to the selected target",
|
||||
Long: `Appends a pattern. If the argument is an existing directory, it is
|
||||
normalised to a repo-root-relative gitignore pattern (trailing slash).
|
||||
Raw glob patterns (containing *, ?, [, leading / or !) are written
|
||||
verbatim.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runConfigExcludeAdd,
|
||||
}
|
||||
|
||||
var configExcludeListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "Show the effective ignore list with source annotations",
|
||||
RunE: runConfigExcludeList,
|
||||
}
|
||||
|
||||
var configExcludeRemoveCmd = &cobra.Command{
|
||||
Use: "remove <path-or-pattern>",
|
||||
Short: "Remove a pattern from the selected target",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runConfigExcludeRemove,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(configCmd)
|
||||
configCmd.AddCommand(configExcludeCmd)
|
||||
configExcludeCmd.AddCommand(configExcludeAddCmd)
|
||||
configExcludeCmd.AddCommand(configExcludeListCmd)
|
||||
configExcludeCmd.AddCommand(configExcludeRemoveCmd)
|
||||
|
||||
for _, c := range []*cobra.Command{configExcludeAddCmd, configExcludeRemoveCmd} {
|
||||
c.Flags().BoolVar(&excludeGlobalFlag, "global", false,
|
||||
"write to ~/.gortex/config.yaml (GlobalConfig.exclude)")
|
||||
c.Flags().StringVar(&excludeRepoFlag, "repo", "",
|
||||
"write to the named RepoEntry in the global config")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Target abstraction ---
|
||||
|
||||
// excludeTarget encapsulates load/save for one of the three writable
|
||||
// exclude locations. repoRoot is set for workspace targets (used to
|
||||
// anchor path normalisation); it's empty for global and repo-entry
|
||||
// targets where patterns are not path-anchored.
|
||||
type excludeTarget struct {
|
||||
label string
|
||||
repoRoot string // "" for non-workspace targets
|
||||
|
||||
load func() ([]string, error)
|
||||
save func(patterns []string) error
|
||||
}
|
||||
|
||||
func resolveTarget() (*excludeTarget, error) {
|
||||
if excludeGlobalFlag && excludeRepoFlag != "" {
|
||||
return nil, fmt.Errorf("--global and --repo are mutually exclusive")
|
||||
}
|
||||
switch {
|
||||
case excludeGlobalFlag:
|
||||
return newGlobalTarget()
|
||||
case excludeRepoFlag != "":
|
||||
return newRepoEntryTarget(excludeRepoFlag)
|
||||
default:
|
||||
return newWorkspaceTarget()
|
||||
}
|
||||
}
|
||||
|
||||
func newWorkspaceTarget() (*excludeTarget, error) {
|
||||
path, root, err := findWorkspaceConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &excludeTarget{
|
||||
label: "workspace " + path,
|
||||
repoRoot: root,
|
||||
load: func() ([]string, error) {
|
||||
cfg, err := readWorkspaceYAML(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// Fold legacy keys so `list` and `remove` see what the old
|
||||
// file actually contributed.
|
||||
patterns := append([]string{}, cfg.Exclude...)
|
||||
if len(cfg.Exclude) == 0 {
|
||||
patterns = append(patterns, cfg.Index.Exclude...)
|
||||
patterns = append(patterns, cfg.Watch.Exclude...)
|
||||
}
|
||||
return patterns, nil
|
||||
},
|
||||
save: func(patterns []string) error {
|
||||
cfg, err := readWorkspaceYAML(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
cfg.Exclude = patterns
|
||||
// Scrub legacy keys once the user has written anything to
|
||||
// the new shape — they've been migrated.
|
||||
cfg.Index.Exclude = nil
|
||||
cfg.Watch.Exclude = nil
|
||||
return writeWorkspaceYAML(path, cfg)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newGlobalTarget() (*excludeTarget, error) {
|
||||
return &excludeTarget{
|
||||
label: "global " + config.DefaultGlobalConfigPath(),
|
||||
load: func() ([]string, error) {
|
||||
gc, err := config.LoadGlobal()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]string{}, gc.Exclude...), nil
|
||||
},
|
||||
save: func(patterns []string) error {
|
||||
gc, err := config.LoadGlobal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gc.Exclude = patterns
|
||||
return gc.Save()
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newRepoEntryTarget(name string) (*excludeTarget, error) {
|
||||
return &excludeTarget{
|
||||
label: "repo entry " + name + " in " + config.DefaultGlobalConfigPath(),
|
||||
load: func() ([]string, error) {
|
||||
gc, err := config.LoadGlobal()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry := gc.FindRepoByPrefix(name)
|
||||
if entry == nil {
|
||||
return nil, fmt.Errorf("no repo entry named %q in global config", name)
|
||||
}
|
||||
return append([]string{}, entry.Exclude...), nil
|
||||
},
|
||||
save: func(patterns []string) error {
|
||||
gc, err := config.LoadGlobal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry := gc.FindRepoByPrefix(name)
|
||||
if entry == nil {
|
||||
return fmt.Errorf("no repo entry named %q in global config", name)
|
||||
}
|
||||
entry.Exclude = patterns
|
||||
return gc.Save()
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Command implementations ---
|
||||
|
||||
func runConfigExcludeAdd(_ *cobra.Command, args []string) error {
|
||||
t, err := resolveTarget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pattern, err := normalizePattern(args[0], t.repoRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := t.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if slices.Contains(existing, pattern) {
|
||||
fmt.Fprintf(os.Stderr, "[gortex] already present in %s: %s\n", t.label, pattern)
|
||||
return nil
|
||||
}
|
||||
existing = append(existing, pattern)
|
||||
if err := t.save(existing); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[gortex] added %q to %s\n", pattern, t.label)
|
||||
notifyDaemonReload()
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigExcludeRemove(_ *cobra.Command, args []string) error {
|
||||
t, err := resolveTarget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := t.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Accept both the raw arg and its normalised form so a user can
|
||||
// remove by path just as they added.
|
||||
candidates := map[string]struct{}{args[0]: {}}
|
||||
if p, err := normalizePattern(args[0], t.repoRoot); err == nil {
|
||||
candidates[p] = struct{}{}
|
||||
}
|
||||
|
||||
filtered := existing[:0]
|
||||
removed := false
|
||||
for _, p := range existing {
|
||||
if _, drop := candidates[p]; drop {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
if !removed {
|
||||
return fmt.Errorf("pattern not found in %s", t.label)
|
||||
}
|
||||
if err := t.save(filtered); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[gortex] removed %q from %s\n", args[0], t.label)
|
||||
notifyDaemonReload()
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigExcludeList(_ *cobra.Command, _ []string) error {
|
||||
rows := [][2]string{}
|
||||
for _, p := range excludes.Builtin {
|
||||
rows = append(rows, [2]string{"builtin", p})
|
||||
}
|
||||
|
||||
gc, err := config.LoadGlobal()
|
||||
if err == nil {
|
||||
for _, p := range gc.Exclude {
|
||||
rows = append(rows, [2]string{"global", p})
|
||||
}
|
||||
}
|
||||
|
||||
wsPath, wsRoot, wsErr := findWorkspaceConfigPath()
|
||||
if wsErr == nil && wsRoot != "" {
|
||||
wsPrefix := filepath.Base(wsRoot)
|
||||
if gc != nil {
|
||||
if entry := gc.FindRepoByPrefix(wsPrefix); entry != nil {
|
||||
for _, p := range entry.Exclude {
|
||||
rows = append(rows, [2]string{"repo:" + wsPrefix, p})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only read the workspace file if it actually exists; findWorkspaceConfigPath
|
||||
// returns a prospective path for the create case.
|
||||
if _, err := os.Stat(wsPath); err == nil {
|
||||
ws, err := readWorkspaceYAML(wsPath)
|
||||
if err == nil && ws != nil {
|
||||
for _, p := range ws.Exclude {
|
||||
rows = append(rows, [2]string{"workspace", p})
|
||||
}
|
||||
if len(ws.Exclude) == 0 {
|
||||
for _, p := range ws.Index.Exclude {
|
||||
rows = append(rows, [2]string{"workspace (legacy index.exclude)", p})
|
||||
}
|
||||
for _, p := range ws.Watch.Exclude {
|
||||
rows = append(rows, [2]string{"workspace (legacy watch.exclude)", p})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
width := 0
|
||||
for _, r := range rows {
|
||||
if len(r[0]) > width {
|
||||
width = len(r[0])
|
||||
}
|
||||
}
|
||||
for _, r := range rows {
|
||||
fmt.Printf("[%-*s] %s\n", width, r[0], r[1])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// normalizePattern converts an arg to a canonical pattern.
|
||||
// If the arg looks like a glob (contains *, ?, [, or leading ! or /),
|
||||
// it's returned verbatim. If it's an existing filesystem path under
|
||||
// repoRoot, it's normalised to a repo-relative gitignore pattern
|
||||
// (trailing "/" for directories). Otherwise returned as-is — users
|
||||
// may intentionally write literal patterns we can't stat.
|
||||
func normalizePattern(arg, repoRoot string) (string, error) {
|
||||
if looksLikeGlob(arg) {
|
||||
return arg, nil
|
||||
}
|
||||
if repoRoot == "" {
|
||||
return arg, nil
|
||||
}
|
||||
absArg, err := filepath.Abs(arg)
|
||||
if err != nil {
|
||||
return arg, nil
|
||||
}
|
||||
info, err := os.Stat(absArg)
|
||||
if err != nil {
|
||||
return arg, nil
|
||||
}
|
||||
rel, err := filepath.Rel(repoRoot, absArg)
|
||||
if err != nil {
|
||||
return arg, nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == "." {
|
||||
return "", fmt.Errorf("refusing to ignore the repo root")
|
||||
}
|
||||
if strings.HasPrefix(rel, "../") || rel == ".." {
|
||||
return "", fmt.Errorf("path %s is outside the repo root %s", arg, repoRoot)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return rel + "/", nil
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// looksLikeGlob reports whether s has pattern syntax that should be
|
||||
// preserved verbatim rather than treated as a filesystem path. A leading
|
||||
// "/" is intentionally NOT treated as a glob marker — users more often
|
||||
// pass an absolute filesystem path than a root-anchored gitignore
|
||||
// pattern; the latter is better expressed by running the command from
|
||||
// the repo root with a relative arg.
|
||||
func looksLikeGlob(s string) bool {
|
||||
if strings.HasPrefix(s, "!") {
|
||||
return true
|
||||
}
|
||||
return strings.ContainsAny(s, "*?[")
|
||||
}
|
||||
|
||||
// findWorkspaceConfigPath walks up from the cwd looking for an existing
|
||||
// .gortex.yaml. When one is found, its path and containing dir are
|
||||
// returned. Otherwise it returns a prospective path at the nearest
|
||||
// git root (or cwd as a last resort) so `add` can create the file.
|
||||
func findWorkspaceConfigPath() (path, repoRoot string, err error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
dir := cwd
|
||||
for {
|
||||
candidate := filepath.Join(dir, ".gortex.yaml")
|
||||
if _, statErr := os.Stat(candidate); statErr == nil {
|
||||
return candidate, dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
if root := gitToplevel(cwd); root != "" {
|
||||
return filepath.Join(root, ".gortex.yaml"), root, nil
|
||||
}
|
||||
return filepath.Join(cwd, ".gortex.yaml"), cwd, nil
|
||||
}
|
||||
|
||||
func gitToplevel(start string) string {
|
||||
dir := start
|
||||
for {
|
||||
if info, err := os.Stat(filepath.Join(dir, ".git")); err == nil && info.IsDir() {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return ""
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// readWorkspaceYAML loads .gortex.yaml. Returns nil with no error when
|
||||
// the file is absent — the workspace target creates it on save.
|
||||
func readWorkspaceYAML(path string) (*config.Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var cfg config.Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// writeWorkspaceYAML writes .gortex.yaml. The round-trip through the
|
||||
// Config struct loses comments, which is an accepted trade-off until
|
||||
// someone needs preservation.
|
||||
func writeWorkspaceYAML(path string, cfg *config.Config) error {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// notifyDaemonReload sends a Reload RPC when a daemon is running so
|
||||
// exclude changes take effect without a restart. Non-fatal on error —
|
||||
// the written config will be picked up on the daemon's next start.
|
||||
func notifyDaemonReload() {
|
||||
if !daemon.IsRunning() {
|
||||
return
|
||||
}
|
||||
c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeControl, ClientName: "cli-config"})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
resp, err := c.Control(daemon.ControlReload, nil)
|
||||
if err != nil || !resp.OK {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "[gortex] daemon reloaded")
|
||||
}
|
||||
Reference in New Issue
Block a user