Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

471 lines
16 KiB
Go

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 <path>",
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 <path>",
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)
}