package main import ( "fmt" "io" "os" "path/filepath" "strconv" "time" "github.com/spf13/cobra" "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/indexer" "github.com/zzet/gortex/internal/pathkey" "github.com/zzet/gortex/internal/progress" "github.com/zzet/gortex/internal/tui" ) var ( trackName string trackAsWorktree bool trackWait bool trackWaitTimeout time.Duration ) // trackStatusFn fetches the daemon status; indirected through a package var so // the --wait poll loop can be exercised in tests without a running daemon. var trackStatusFn = fetchDaemonStatusForCLI // trackPollInterval is how often --wait re-queries the daemon. A package var so // tests can drop it to a sub-millisecond tick instead of waiting whole seconds. var trackPollInterval = time.Second var trackCmd = &cobra.Command{ Use: "track ", Short: "Add a repository to the tracked workspace", Long: "Resolves the path to absolute, validates it exists, and adds it to the global config.", Args: cobra.ExactArgs(1), RunE: runTrack, } var untrackCmd = &cobra.Command{ Use: "untrack ", Short: "Remove a repository from the tracked workspace", Long: "Resolves the path and removes the matching entry from the global config.", Args: cobra.ExactArgs(1), RunE: runUntrack, } func init() { trackCmd.Flags().StringVar(&trackName, "name", "", "Explicit repo prefix override (default: directory basename)") trackCmd.Flags().BoolVar(&trackAsWorktree, "as-worktree", false, "Track a linked git worktree as an independent instance even when its repo is already tracked elsewhere") trackCmd.Flags().BoolVar(&trackWait, "wait", false, "Block until the daemon has indexed the repo and the graph is queryable (useful for CI / one-shot scripts)") trackCmd.Flags().DurationVar(&trackWaitTimeout, "wait-timeout", 10*time.Minute, "With --wait, fail if indexing has not settled within this duration (0 = wait forever)") rootCmd.AddCommand(trackCmd) rootCmd.AddCommand(untrackCmd) } // declaredWorkspace reads the `workspace:` slug from a repo's own // `.gortex.yaml`, or "" when the file is absent or declares none. Used by // the daemon-less track path to derive a stable worktree-instance prefix // the same way the daemon would. func declaredWorkspace(repoPath string) string { cfgFile := filepath.Join(repoPath, ".gortex.yaml") if _, err := os.Stat(cfgFile); err != nil { return "" } cfg, err := config.Load(cfgFile) if err != nil || cfg == nil { return "" } return cfg.Workspace } func runTrack(cmd *cobra.Command, args []string) error { rawPath := args[0] w := cmd.ErrOrStderr() // Resolve to absolute path. Normalise the volume (upper-case a Windows // drive letter) for this NEW entry so it converges with os.Getwd's // convention — the volume is never part of a repo basename, so this is // cosmetic and cannot rotate a repo prefix. No-op on POSIX. absPath, err := filepath.Abs(rawPath) if err != nil { return fmt.Errorf("resolving path %s: %w", rawPath, err) } absPath = pathkey.NormalizeVolume(absPath) // Validate path exists and is a directory. info, err := os.Stat(absPath) if err != nil { return fmt.Errorf("path does not exist: %s", absPath) } if !info.IsDir() { return fmt.Errorf("path is not a directory: %s", absPath) } emitTrackBanner(w, absPath, daemon.IsRunning()) // 1. Config write FIRST. Registering a repo is a config operation // that always succeeds with no daemon, so `gortex track` is // offline-safe — the durable source of truth is written before any // daemon contact. gc, err := config.LoadGlobal() if err != nil { return fmt.Errorf("loading global config: %w", err) } // Heal any pre-existing duplicate case-variant entries so the track // path (and the persisted config) sees a clean list (#270). healDuplicateRepos(gc, nil) already := false for _, existing := range gc.Repos { if existingAbs, _ := filepath.Abs(existing.Path); pathkey.SamePathIdentity(existingAbs, absPath) { already = true break } } var prefix string if already { prefix = config.ResolvePrefix(config.RepoEntry{Path: absPath, Name: trackName}) } else { entry := config.RepoEntry{Path: absPath} switch { case trackName != "": entry.Name = trackName case trackAsWorktree: // The AsWorktree flag is not persisted, so pin the derived // instance prefix as the entry Name now. The daemon reproduces // it intrinsically for a declared-workspace worktree, but a // forced (branch-tagged) instance must be recorded here. base := config.ResolvePrefix(entry) if name, sep := indexer.WorktreeInstanceName(absPath, base, declaredWorkspace(absPath), true); sep { entry.Name = name } } if err := gc.AddRepo(entry); err != nil { return err } if err := gc.Save(); err != nil { return fmt.Errorf("saving global config: %w", err) } prefix = config.ResolvePrefix(entry) } // 2. Best-effort daemon: bring it up (single-flight) and hand it the // repo so indexing starts now. Spawn / control failure is // NON-FATAL — the config write above already persisted the repo, // so we degrade to the offline summary instead of erroring. if ensureDaemonReady(daemon.ParseAutostart()) != daemonUnavailable { if err := notifyDaemonTrack(absPath); err == nil { // --wait blocks until the daemon has actually indexed the repo so // a following `gortex analyze` / query sees a complete graph. if trackWait { if werr := waitForRepoIndexed(w, absPath, trackWaitTimeout); werr != nil { return werr } } emitTrackSummary(w, absPath, trackResult{viaDaemon: true, prefix: prefix, alreadyTracked: already}) return nil } else if trackWait { // The repo is persisted, but --wait promised a queryable graph we // can no longer deliver — surface that rather than a soft success. return fmt.Errorf("--wait: daemon did not accept the repo: %w", err) } } else if trackWait { return fmt.Errorf("--wait requires a running daemon, but none is available — start it with `gortex daemon start --detach`") } // 3. Daemon unavailable (autostart off, spawn failed/timed out, or // the control hop failed). The repo is tracked on disk; tell the // user the daemon will pick it up later — success, not error. emitTrackSummary(w, absPath, trackResult{configOnly: true, repoCount: len(gc.Repos), prefix: prefix, alreadyTracked: already}) return nil } // repoNodeCount returns the indexed node count for the repo at absPath in the // daemon status, or -1 if the daemon has not registered the repo yet. func repoNodeCount(st daemon.StatusResponse, absPath string) int { for _, r := range st.TrackedRepos { if ra, err := filepath.Abs(r.Path); err == nil && pathkey.EqualPaths(ra, absPath) { return r.Nodes } } return -1 } // indexSettled reports whether the repo at absPath looks fully indexed: the // daemon has it with a non-zero node count that has stopped moving (equal to // prevNodes, the previous poll's reading) and the graph is resolved (Ready). // prevNodes is -1 before the first reading. Requiring a stable count across two // polls is a per-repo heuristic that holds even on a warm multi-repo daemon // where the global Ready flag alone would be insufficient. func indexSettled(st daemon.StatusResponse, absPath string, prevNodes int) (settled bool, nodes int) { nodes = repoNodeCount(st, absPath) if nodes <= 0 || !st.Ready { return false, nodes } return nodes == prevNodes, nodes } // waitForRepoIndexed polls the daemon until the repo at absPath has settled // (see indexSettled) or timeout elapses. timeout <= 0 waits forever. On a TTY // the wait renders as a live step with the node count ticking up; on a pipe // (or with --no-progress) it prints one start line, slow heartbeats, and the // settle summary. func waitForRepoIndexed(w io.Writer, absPath string, timeout time.Duration) error { tr := progress.NewTracker(w) if noProgress { tr = progress.NewTracker(w, progress.WithoutAnimation()) } tr.Start("waiting for indexing to settle (--wait)") step := tr.StartStep("indexing " + filepath.Base(absPath)) step.SetUnit("nodes") var deadline time.Time if timeout > 0 { deadline = time.Now().Add(timeout) } prevNodes := -1 for { if st, err := trackStatusFn(); err == nil { settled, nodes := indexSettled(st, absPath, prevNodes) if nodes > 0 { step.Progress(int64(nodes), 0) } if settled { step.DoneAs("index settled") tr.Done("indexed", humanizeInt(nodes)+" nodes") return nil } prevNodes = nodes } if timeout > 0 && time.Now().After(deadline) { err := fmt.Errorf("--wait: timed out after %s waiting for %s to index", timeout, absPath) tr.Fail(err) return err } time.Sleep(trackPollInterval) } } // trackResult bundles the outcome of runTrack so emitTrackSummary can pick the // right summary card variant without re-deriving facts from the call sites. type trackResult struct { viaDaemon bool configOnly bool alreadyTracked bool repoCount int // tracked repo count *after* this call (configOnly path) prefix string // the prefix the repo was registered under (may differ from basename for worktree instances) } // emitTrackBanner prints the gortex mesh banner + subtitle indicating which // path will be tracked and whether a daemon will pick it up immediately. Only // emitted when stderr is a TTY — non-TTY runs (CI scripts) stay quiet so // existing piped output still parses. // notifyDaemonTrack hands the repo to a running daemon via the control // socket. It returns an error when the daemon can't be reached or rejects // the request; the caller treats that as non-fatal because the config // write already persisted the repo. func notifyDaemonTrack(absPath string) error { c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeControl, ClientName: "cli"}) if err != nil { return err } defer c.Close() resp, err := c.Control(daemon.ControlTrack, daemon.TrackParams{ Path: absPath, Name: trackName, AsWorktree: trackAsWorktree, }) if err != nil { return err } if !resp.OK { return fmt.Errorf("track rejected: %s %s", resp.ErrorCode, resp.ErrorMsg) } return nil } func emitTrackBanner(w io.Writer, absPath string, daemonUp bool) { if !progress.IsTTY(w) { return } sub := "Adding repository to the workspace." if daemonUp { sub = "Adding repository — daemon is up, indexing will start immediately." } banner := tui.Banner{ Title: "gortex track", Subtitle: sub, }.Render() fmt.Fprintln(w) fmt.Fprintln(w, banner) fmt.Fprintln(w, " "+progress.Row("path", absPath, 6)) fmt.Fprintln(w) } // emitTrackSummary prints the post-track summary card. Three variants: via // daemon (indexing is live), config-only (daemon will pick it up later), or // already tracked (idempotent no-op). func emitTrackSummary(w io.Writer, absPath string, r trackResult) { if !progress.IsTTY(w) { // Preserve the legacy one-line output for non-TTY callers so // scripts that grep this line keep working. suffix := "" if r.prefix != "" && r.prefix != filepath.Base(absPath) { suffix = fmt.Sprintf(" as %q", r.prefix) } switch { case r.viaDaemon: fmt.Fprintf(w, "[gortex] tracked %s%s (via daemon)\n", absPath, suffix) case r.alreadyTracked: fmt.Fprintf(w, "[gortex] already tracked: %s\n", absPath) case r.configOnly: fmt.Fprintf(w, "[gortex] tracked %s%s (config only — start daemon to index)\n", absPath, suffix) } return } var stats []string if r.prefix != "" && r.prefix != filepath.Base(absPath) { stats = append(stats, progress.Stat("prefix", r.prefix, progress.StatGood)) } switch { case r.viaDaemon: stats = append(stats, progress.Stat("via daemon", "", progress.StatGood)) stats = append(stats, progress.Stat("indexing", "live", progress.StatGood)) case r.alreadyTracked: stats = append(stats, progress.Stat("already", "tracked", progress.StatNeutral)) if r.repoCount > 0 { stats = append(stats, progress.Stat(strconv.Itoa(r.repoCount), "tracked repos", progress.StatNeutral)) } case r.configOnly: stats = append(stats, progress.Stat("written to", "global config", progress.StatGood)) stats = append(stats, progress.Stat("daemon", "offline — start to index", progress.StatWarn)) } fmt.Fprintln(w, " "+progress.StyleOK.Render("✓")+" "+progress.StyleStrong.Render(absPath)) fmt.Fprintln(w, " "+progress.StatStrip(stats...)) switch { case r.viaDaemon: fmt.Fprintln(w, "\n "+progress.Caption("watch progress: `gortex daemon status --watch`")) case r.configOnly: fmt.Fprintln(w, "\n "+progress.Caption("next: `gortex daemon start --detach` to index this repo")) } fmt.Fprintln(w) } func runUntrack(cmd *cobra.Command, args []string) error { rawPath := args[0] w := cmd.ErrOrStderr() // Argument can be either a path or a repo prefix; the daemon accepts // both. Resolve to absolute only when it looks like a path (starts // with / or . or has a path separator); otherwise treat as a prefix. target := rawPath if filepath.IsAbs(rawPath) || rawPath == "." || rawPath == ".." { abs, err := filepath.Abs(rawPath) if err != nil { return fmt.Errorf("resolving path %s: %w", rawPath, err) } target = abs } else if info, statErr := os.Stat(rawPath); statErr == nil && info.IsDir() { // A relative arg that names an existing directory (e.g. `foo/bar` // from cwd) is a path, not a prefix — absolutise it so it resolves // against the tracked roots. A bare prefix that names no directory // keeps its as-is behaviour. if abs, err := filepath.Abs(rawPath); err == nil { target = abs } } emitUntrackBanner(w, target, daemon.IsRunning()) if daemon.IsRunning() { c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeControl, ClientName: "cli"}) if err == nil { defer c.Close() resp, ctlErr := c.Control(daemon.ControlUntrack, daemon.UntrackParams{PathOrPrefix: target}) if ctlErr != nil { return ctlErr } if !resp.OK { return fmt.Errorf("untrack rejected: %s %s", resp.ErrorCode, resp.ErrorMsg) } emitUntrackSummary(w, target, untrackResult{viaDaemon: true}) return nil } } // Standalone fallback. gc, err := config.LoadGlobal() if err != nil { return fmt.Errorf("loading global config: %w", err) } if err := gc.RemoveRepo(target); err != nil { return err } if err := gc.Save(); err != nil { return fmt.Errorf("saving global config: %w", err) } emitUntrackSummary(w, target, untrackResult{configOnly: true, repoCount: len(gc.Repos)}) return nil } // untrackResult mirrors trackResult — kept distinct so the two summaries can // drift apart later (e.g. untrack might want to show whether the repo had // pending edits before removal) without one breaking the other. type untrackResult struct { viaDaemon bool configOnly bool repoCount int // tracked repo count *after* removal (configOnly path) } // emitUntrackBanner prints the gortex mesh banner + subtitle indicating which // path is being untracked and where the change will land (daemon vs config). func emitUntrackBanner(w io.Writer, target string, daemonUp bool) { if !progress.IsTTY(w) { return } sub := "Removing repository from the workspace." if daemonUp { sub = "Removing repository — daemon will drop the index immediately." } banner := tui.Banner{ Title: "gortex untrack", Subtitle: sub, }.Render() fmt.Fprintln(w) fmt.Fprintln(w, banner) fmt.Fprintln(w, " "+progress.Row("target", target, 8)) fmt.Fprintln(w) } // emitUntrackSummary prints the post-untrack summary card. Same TTY vs. // non-TTY split as the track sibling so script parsers keep working. func emitUntrackSummary(w io.Writer, target string, r untrackResult) { if !progress.IsTTY(w) { switch { case r.viaDaemon: fmt.Fprintf(w, "[gortex] untracked %s (via daemon)\n", target) case r.configOnly: fmt.Fprintf(w, "[gortex] untracked %s (config only)\n", target) } return } var stats []string switch { case r.viaDaemon: stats = append(stats, progress.Stat("via daemon", "", progress.StatGood)) stats = append(stats, progress.Stat("index", "dropped", progress.StatGood)) case r.configOnly: stats = append(stats, progress.Stat("removed from", "global config", progress.StatGood)) if r.repoCount >= 0 { stats = append(stats, progress.Stat(strconv.Itoa(r.repoCount), "repos remain", progress.StatNeutral)) } } fmt.Fprintln(w, " "+progress.StyleOK.Render("✓")+" "+progress.StyleStrong.Render(target)) fmt.Fprintln(w, " "+progress.StatStrip(stats...)) fmt.Fprintln(w) }