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
366 lines
12 KiB
Go
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)
|
|
}
|