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

366 lines
12 KiB
Go

package main
import (
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strconv"
"time"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/spf13/cobra"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/graph/store_sqlite"
"github.com/zzet/gortex/internal/persistence"
"github.com/zzet/gortex/internal/platform"
"github.com/zzet/gortex/internal/progress"
"github.com/zzet/gortex/internal/tui"
)
var reposJSON bool
// reposCacheDir is the persistence-store directory `gortex repos`
// inspects for index freshness. Empty resolves to the default
// (~/.gortex/cache/) — the same slot `gortex server` / `gortex mcp`
// persist to. Overridable so tests can point at a temp store.
var reposCacheDir string
// reposBackendPath is the on-disk SQLite backend file `gortex repos`
// reads index-freshness provenance (the repo_index_state table) from.
// Empty resolves to the daemon's default store (~/.gortex/store/store.sqlite).
// Overridable so tests can point at an isolated store.
var reposBackendPath string
var reposCmd = &cobra.Command{
Use: "repos",
Short: "List every tracked repository with its git head and index freshness",
Long: `Lists the repositories registered in the global config
(~/.gortex/config.yaml).
For each repo the command reports the current git HEAD commit and an
index-freshness indicator: when the persisted index was last built and
whether that index still matches HEAD. A repo is "stale" when HEAD has
moved past the commit the cached index was built from, or when no index
has been persisted yet.
The default output is a table; --json emits the same data as a JSON
array suitable for scripting.`,
RunE: runRepos,
}
func init() {
reposCmd.Flags().BoolVar(&reposJSON, "json", false, "emit machine-readable JSON instead of a table")
rootCmd.AddCommand(reposCmd)
}
// repoStatus is one repository's entry in the `gortex repos` output —
// identity plus the git head commit and index-freshness facts. It is
// the JSON shape emitted under --json; the table renderer projects the
// same struct into columns.
type repoStatus struct {
// Name is the repo's configured name, falling back to the path
// basename when the global config declares no explicit name.
Name string `json:"name"`
// Path is the absolute on-disk path of the repository.
Path string `json:"path"`
// Workspace is the workspace slug the repo's nodes are keyed on
// (the global-config override; empty when none is declared).
Workspace string `json:"workspace,omitempty"`
// HeadCommit is the current git HEAD commit SHA, or empty when
// the path is not a git repository / git is unavailable.
HeadCommit string `json:"head_commit"`
// Branch is the current git branch, empty for a detached HEAD.
Branch string `json:"branch,omitempty"`
// IndexedCommit is the commit SHA the persisted index was built
// from. Empty when no index snapshot exists yet.
IndexedCommit string `json:"indexed_commit,omitempty"`
// LastIndexed is the timestamp the persisted index was built.
// Nil (omitted from JSON) when the repo has never been indexed.
LastIndexed *time.Time `json:"last_indexed,omitempty"`
// Stale is true when the persisted index does not match the
// current HEAD — either HEAD moved past IndexedCommit or no
// index has been persisted at all.
Stale bool `json:"stale"`
// Indexed is true when a persisted index snapshot was found for
// the repo's current branch slot.
Indexed bool `json:"indexed"`
// IndexedDirty is true when the recorded index was built from a
// working tree with uncommitted changes (the repo_index_state.dirty
// flag). Omitted from JSON when false / unknown. The index still
// counts as fresh when its commit matches HEAD — this is provenance,
// not a staleness signal.
IndexedDirty bool `json:"indexed_dirty,omitempty"`
}
func runRepos(cmd *cobra.Command, _ []string) error {
repos, err := loadGlobalRepos()
if err != nil {
return err
}
// Primary freshness source: the daemon's on-disk SQLite backend
// records one repo_index_state row per repo at the end of every
// (re)index. Read it once, read-only, so a single open serves the
// whole list. An unavailable / empty store yields an empty map and
// we fall back to the snapshot store below.
indexStates := loadRepoIndexStates()
// The persistence store is the legacy fallback freshness source —
// the embedded `gortex mcp --index` path persists a per-repo
// snapshot here on shutdown. An empty cache dir resolves to the
// default (~/.gortex/cache/).
store, err := persistence.NewFileStore(reposCacheDir, version)
if err != nil {
return fmt.Errorf("open persistence store: %w", err)
}
defer store.Close()
entries := make([]repoStatus, 0, len(repos))
for _, r := range repos {
entries = append(entries, describeRepo(store, indexStates, len(repos), r))
}
// Stable order regardless of config-file ordering so scripted
// diffs and the table stay deterministic.
sort.Slice(entries, func(i, j int) bool {
if entries[i].Name != entries[j].Name {
return entries[i].Name < entries[j].Name
}
return entries[i].Path < entries[j].Path
})
if reposJSON {
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(entries)
}
return renderReposTable(cmd, entries)
}
// loadRepoIndexStates reads the daemon's per-repo index-freshness rows
// (the SQLite repo_index_state table) keyed by repo prefix. It opens the
// backend store read-only so it is safe to run while a daemon holds the
// same store. Any failure (no store yet, unreadable cache) degrades to an
// empty map so `gortex repos` falls back to the snapshot store rather than
// erroring out.
func loadRepoIndexStates() map[string]graph.RepoIndexState {
path := reposBackendPath
if path == "" {
path = filepath.Join(platform.StoreDir(), "store.sqlite")
}
states, err := store_sqlite.ReadRepoIndexStates(path)
if err != nil {
return map[string]graph.RepoIndexState{}
}
return states
}
// describeRepo resolves one RepoEntry into a repoStatus by reading the
// repo's current git HEAD and looking up its recorded index freshness.
//
// The authoritative source is the daemon's repo_index_state row, keyed by
// the repo's resolved prefix (config.ResolvePrefix — the entry Name, else
// the path basename); this is what the daemon writes when it tracks or warms up a repo.
// When there is exactly one tracked repo, a lone-repo index keyed under the
// empty prefix counts too. Failing that, the legacy snapshot store (keyed by
// canonical path + branch) written by the embedded `gortex mcp --index` path
// is consulted. A repo is fresh when the recorded commit matches HEAD.
func describeRepo(store persistence.Store, indexStates map[string]graph.RepoIndexState, repoCount int, r config.RepoEntry) repoStatus {
head := gitCommitHash(r.Path)
branch := gitBranch(r.Path)
entry := repoStatus{
Name: repoLabel(r),
Path: r.Path,
Workspace: r.Workspace,
HeadCommit: head,
Branch: branch,
// Default to stale; cleared below only when a recorded
// index is found whose commit matches HEAD.
Stale: true,
}
// Primary: the daemon's freshness row for this repo's prefix.
prefix := config.ResolvePrefix(r)
st, ok := indexStates[prefix]
if !ok && repoCount == 1 {
// A single-repo (lone) index is keyed under the empty prefix.
st, ok = indexStates[""]
}
if ok {
entry.Indexed = true
entry.IndexedCommit = st.IndexedSHA
entry.IndexedDirty = st.Dirty
if st.IndexedAt > 0 {
ts := time.Unix(st.IndexedAt, 0)
entry.LastIndexed = &ts
}
// Fresh only when the recorded index was built from the exact
// commit HEAD currently points at. An empty HeadCommit (not a
// git repo) or an empty recorded SHA can never be fresh.
entry.Stale = head == "" || st.IndexedSHA == "" || st.IndexedSHA != head
return entry
}
// Fallback: the embedded-server snapshot, keyed under the canonical
// (main) repo path so every worktree of a repo shares a base slot.
repoKey := canonicalRepo(r.Path)
snap, loadErr := store.Load(repoKey, branch, head)
if loadErr != nil || snap == nil {
// ErrNotFound (or any read error) — treat as never indexed.
return entry
}
entry.Indexed = true
entry.IndexedCommit = snap.CommitHash
indexedAt := snap.IndexedAt
entry.LastIndexed = &indexedAt
entry.Stale = head == "" || snap.CommitHash != head
return entry
}
// renderReposTable prints the repo list as an ASCII table — the default
// human-readable form. Columns mirror the repoStatus JSON fields. On a TTY
// we wrap the table in a styled banner + bottom stat strip; on a non-TTY
// (script piping `gortex repos | grep …`) we keep only the bare table so
// parser-shaped scripts don't break.
func renderReposTable(cmd *cobra.Command, entries []repoStatus) error {
out := cmd.OutOrStdout()
stderr := cmd.ErrOrStderr()
tty := progress.IsTTY(stderr) && !noProgress
if len(entries) == 0 {
if tty {
emitReposBanner(stderr)
fmt.Fprintln(stderr, " "+progress.StyleHint.Render("◌ no tracked repos — run `gortex track <path>` to add one"))
fmt.Fprintln(stderr)
} else {
fmt.Fprintln(out, "(no tracked repos)")
}
return nil
}
if tty {
emitReposBanner(stderr)
}
t := table.NewWriter()
t.SetOutputMirror(out)
t.SetStyle(table.StyleLight)
t.AppendHeader(table.Row{"repo", "head", "indexed", "last indexed", "freshness", "path"})
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, Align: text.AlignLeft},
{Number: 2, Align: text.AlignLeft},
{Number: 3, Align: text.AlignLeft},
{Number: 4, Align: text.AlignLeft},
{Number: 5, Align: text.AlignLeft},
{Number: 6, Align: text.AlignLeft},
})
for _, e := range entries {
t.AppendRow(table.Row{
e.Name,
shortSHA(e.HeadCommit),
shortSHA(e.IndexedCommit),
lastIndexedCell(e),
freshnessCell(e, tty),
e.Path,
})
}
t.Render()
if tty {
emitReposSummary(stderr, entries)
}
return nil
}
// emitReposBanner prints the gortex mesh banner on stderr above the table.
// Keeping the banner on stderr (not stdout) means `gortex repos | grep foo`
// still sees only the table on stdout — the JSON / table is parseable, the
// decoration is purely visual.
func emitReposBanner(w interface{ Write([]byte) (int, error) }) {
banner := tui.Banner{
Title: "gortex repos",
Subtitle: "Every tracked repository with its git head and index freshness.",
}.Render()
fmt.Fprintln(w)
fmt.Fprintln(w, banner)
fmt.Fprintln(w)
}
// emitReposSummary appends a stat strip below the table: total / fresh /
// stale / never-indexed counts so the eye gets the headline at a glance.
func emitReposSummary(w interface{ Write([]byte) (int, error) }, entries []repoStatus) {
fresh, stale, never := 0, 0, 0
for _, e := range entries {
switch {
case !e.Indexed:
never++
case e.Stale:
stale++
default:
fresh++
}
}
stats := []string{
progress.Stat(strconv.Itoa(len(entries)), "tracked", progress.StatNeutral),
progress.Stat(strconv.Itoa(fresh), "fresh", progress.StatGood),
}
if stale > 0 {
stats = append(stats, progress.Stat(strconv.Itoa(stale), "stale", progress.StatWarn))
}
if never > 0 {
stats = append(stats, progress.Stat(strconv.Itoa(never), "never indexed", progress.StatBad))
}
fmt.Fprintln(w)
fmt.Fprintln(w, " "+progress.StatStrip(stats...))
fmt.Fprintln(w)
}
// shortSHA abbreviates a 40-char git SHA to its 12-char prefix for the
// table — the full hash stays in the JSON output. Empty in, empty out.
func shortSHA(sha string) string {
if len(sha) > 12 {
return sha[:12]
}
if sha == "" {
return "(none)"
}
return sha
}
// lastIndexedCell renders the last-indexed timestamp for the table, or
// a placeholder when the repo has never been indexed.
func lastIndexedCell(e repoStatus) string {
if e.LastIndexed == nil {
return "(never)"
}
return e.LastIndexed.Local().Format("2006-01-02 15:04:05")
}
// freshnessCell renders the staleness indicator for the table. On a TTY
// the label is colour-tiered (green/yellow/red) so the eye picks up risk
// from a long list at a glance; non-TTY keeps the plain text so scripts
// that grep for "stale" / "fresh" still match.
func freshnessCell(e repoStatus, tty bool) string {
label := "fresh"
style := progress.StyleOK
switch {
case !e.Indexed:
label = "not indexed"
style = progress.StyleErr
case e.Stale:
label = "stale"
style = progress.StyleHint
}
if !tty {
return label
}
return style.Render(label)
}