778 lines
22 KiB
Go
778 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"ai-workflow-skill/packages/repo-memory-runtime/internal/documents"
|
|
"ai-workflow-skill/packages/repo-memory-runtime/internal/store"
|
|
)
|
|
|
|
func main() {
|
|
os.Exit(Execute(os.Args[1:], os.Stdout, os.Stderr))
|
|
}
|
|
|
|
type stringSliceFlag []string
|
|
|
|
func (s *stringSliceFlag) String() string {
|
|
return strings.Join(*s, ",")
|
|
}
|
|
|
|
func (s *stringSliceFlag) Set(value string) error {
|
|
*s = append(*s, value)
|
|
return nil
|
|
}
|
|
|
|
func runInit(args []string) error {
|
|
fs := flag.NewFlagSet("init", flag.ContinueOnError)
|
|
fs.SetOutput(commandStderr)
|
|
setCommandUsage(fs, "init",
|
|
"Create or migrate the SQLite schema for one repo-memory database.",
|
|
[]string{
|
|
"Run init once before first real use on a new database path.",
|
|
"init is safe to rerun when you need to ensure the schema exists before another command.",
|
|
},
|
|
`repo-memory init --db ~/.codex/data/repo-memory.db`,
|
|
)
|
|
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
st, err := store.Open(*dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer st.Close()
|
|
|
|
if err := st.Init(context.Background()); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(commandStdout, "initialized %s\n", *dbPath)
|
|
return nil
|
|
}
|
|
|
|
func runIngest(args []string) error {
|
|
fs := flag.NewFlagSet("ingest", flag.ContinueOnError)
|
|
fs.SetOutput(commandStderr)
|
|
setCommandUsage(fs, "ingest",
|
|
"Scan markdown knowledge under one repository and import it into repo-memory.",
|
|
[]string{
|
|
"ingest expects a repository root and scans markdown under the configured relative path.",
|
|
"Use ingest for curated docs; use add when you want to record one specific fact manually.",
|
|
},
|
|
`repo-memory ingest --db ~/.codex/data/repo-memory.db --repo /path/to/repo --path docs/ai`,
|
|
)
|
|
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
|
|
repoPath := fs.String("repo", "", "Repository root")
|
|
scanPath := fs.String("path", "docs/ai", "Relative path under repo to scan for markdown")
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if strings.TrimSpace(*repoPath) == "" {
|
|
return fmt.Errorf("--repo is required")
|
|
}
|
|
|
|
absRepo, err := filepath.Abs(*repoPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
st, err := store.Open(*dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer st.Close()
|
|
|
|
if err := st.Init(context.Background()); err != nil {
|
|
return err
|
|
}
|
|
|
|
gitState := detectGitState(absRepo)
|
|
if _, err := st.UpsertRepo(context.Background(), store.RepoState{
|
|
RootPath: absRepo,
|
|
Name: filepath.Base(absRepo),
|
|
VCS: "git",
|
|
LastSeenBranch: gitState.branch,
|
|
LastSeenCommit: gitState.commit,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
docs, err := documents.LoadRepo(absRepo, *scanPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(docs) == 0 {
|
|
return fmt.Errorf("no markdown files found under %s", filepath.Join(absRepo, *scanPath))
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
for _, doc := range docs {
|
|
if err := st.ImportDocument(ctx, doc, store.RepoState{
|
|
RootPath: absRepo,
|
|
Name: filepath.Base(absRepo),
|
|
VCS: "git",
|
|
LastSeenBranch: gitState.branch,
|
|
LastSeenCommit: gitState.commit,
|
|
}); err != nil {
|
|
return fmt.Errorf("ingest %s: %w", doc.DocPath, err)
|
|
}
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(commandStdout, "ingested %d docs from %s\n", len(docs), absRepo)
|
|
return nil
|
|
}
|
|
|
|
func runAdd(args []string) error {
|
|
fs := flag.NewFlagSet("add", flag.ContinueOnError)
|
|
fs.SetOutput(commandStderr)
|
|
setCommandUsage(fs, "add",
|
|
"Insert or update one durable knowledge entry with evidence, aliases, and dependencies.",
|
|
[]string{
|
|
"add requires a repository root because entries are anchored to one repo.",
|
|
"Prefer confirmed only for well-supported facts; use dependencies and evidence fields whenever possible.",
|
|
},
|
|
`repo-memory add --db ~/.codex/data/repo-memory.db --repo /path/to/repo --kind term --key AITask --summary "Plan task model" --source-path app/AITask.java --source-line 42 --status confirmed --alias "AI Task" --dep file:app/AITask.java:hard`,
|
|
)
|
|
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
|
|
repoPath := fs.String("repo", "", "Repository root")
|
|
kind := fs.String("kind", "", "Knowledge kind, e.g. term|chain|danger")
|
|
key := fs.String("key", "", "Stable entry key")
|
|
title := fs.String("title", "", "Display title")
|
|
summary := fs.String("summary", "", "Short summary")
|
|
detail := fs.String("detail", "", "Detailed markdown text")
|
|
status := fs.String("status", "draft", "Entry status")
|
|
confidence := fs.Float64("confidence", 0.7, "Confidence from 0 to 1")
|
|
scopeBranch := fs.String("scope-branch", "", "Optional branch scope")
|
|
scopeModule := fs.String("scope-module", "", "Optional module scope")
|
|
scopePath := fs.String("scope-path-prefix", "", "Optional path prefix scope")
|
|
sourcePath := fs.String("source-path", "", "Main evidence path")
|
|
sourceLine := fs.Int("source-line", 0, "Main evidence line")
|
|
var aliases stringSliceFlag
|
|
var deps stringSliceFlag
|
|
fs.Var(&aliases, "alias", "Alias for this entry (repeatable)")
|
|
fs.Var(&deps, "dep", "Dependency in type:locator[:hard|soft] format (repeatable)")
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if strings.TrimSpace(*repoPath) == "" {
|
|
return fmt.Errorf("--repo is required")
|
|
}
|
|
|
|
absRepo, err := filepath.Abs(*repoPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
st, err := store.Open(*dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer st.Close()
|
|
|
|
if err := st.Init(context.Background()); err != nil {
|
|
return err
|
|
}
|
|
|
|
gitState := detectGitState(absRepo)
|
|
if _, err := st.UpsertRepo(context.Background(), store.RepoState{
|
|
RootPath: absRepo,
|
|
Name: filepath.Base(absRepo),
|
|
VCS: "git",
|
|
LastSeenBranch: gitState.branch,
|
|
LastSeenCommit: gitState.commit,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
input := store.EntryInput{
|
|
RepoRoot: absRepo,
|
|
Kind: *kind,
|
|
Key: *key,
|
|
Title: *title,
|
|
Summary: *summary,
|
|
DetailMD: *detail,
|
|
Status: *status,
|
|
Confidence: *confidence,
|
|
ScopeBranch: *scopeBranch,
|
|
ScopeModule: *scopeModule,
|
|
ScopePathPrefix: *scopePath,
|
|
SourcePath: relativize(absRepo, *sourcePath),
|
|
SourceLine: *sourceLine,
|
|
VerifiedAt: time.Now().UTC().Format(time.RFC3339),
|
|
VerifiedOnCommit: gitState.commit,
|
|
Aliases: aliases,
|
|
Dependencies: parseDeps(absRepo, deps),
|
|
}
|
|
entryID, err := st.UpsertEntry(context.Background(), input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(commandStdout, "upserted entry %d (%s:%s)\n", entryID, input.Kind, input.Key)
|
|
return nil
|
|
}
|
|
|
|
func runSearch(args []string) error {
|
|
fs := flag.NewFlagSet("search", flag.ContinueOnError)
|
|
fs.SetOutput(commandStderr)
|
|
setCommandUsage(fs, "search",
|
|
"Search stored repository knowledge before a deeper code dive.",
|
|
[]string{
|
|
"search requires a non-empty query string.",
|
|
"Use repo filtering when you want to narrow results to one repository name or path fragment.",
|
|
},
|
|
`repo-memory search --db ~/.codex/data/repo-memory.db --repo zeus --query "actionCode fill" --limit 10`,
|
|
)
|
|
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
|
|
query := fs.String("query", "", "Search query")
|
|
repo := fs.String("repo", "", "Optional repo path filter (substring match)")
|
|
limit := fs.Int("limit", 10, "Result limit")
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if strings.TrimSpace(*query) == "" {
|
|
return fmt.Errorf("--query is required")
|
|
}
|
|
|
|
st, err := store.Open(*dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer st.Close()
|
|
|
|
results, err := st.Search(context.Background(), store.SearchParams{
|
|
Query: *query,
|
|
RepoFilter: *repo,
|
|
Limit: *limit,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(results) == 0 {
|
|
_, _ = fmt.Fprintln(commandStdout, "no results")
|
|
return nil
|
|
}
|
|
|
|
for i, r := range results {
|
|
_, _ = fmt.Fprintf(commandStdout, "%d. [%s] %s:%s [%s]\n", i+1, filepath.Base(r.RepoPath), r.Kind, r.Key, r.Status)
|
|
_, _ = fmt.Fprintf(commandStdout, " %s\n", r.Title)
|
|
_, _ = fmt.Fprintf(commandStdout, " %s\n", r.Snippet)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runRepos(args []string) error {
|
|
fs := flag.NewFlagSet("repos", flag.ContinueOnError)
|
|
fs.SetOutput(commandStderr)
|
|
setCommandUsage(fs, "repos",
|
|
"List repositories currently tracked in one repo-memory database.",
|
|
[]string{
|
|
"repos reads only from the memory database; it does not rescan the filesystem.",
|
|
},
|
|
`repo-memory repos --db ~/.codex/data/repo-memory.db`,
|
|
)
|
|
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
st, err := store.Open(*dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer st.Close()
|
|
|
|
repos, err := st.ListRepos(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(repos) == 0 {
|
|
_, _ = fmt.Fprintln(commandStdout, "no repos")
|
|
return nil
|
|
}
|
|
|
|
for _, repo := range repos {
|
|
_, _ = fmt.Fprintf(commandStdout, "- %s (%d entries, updated %s)\n", repo.Path, repo.EntryCount, repo.UpdatedAt.Format(time.RFC3339))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runList(args []string) error {
|
|
fs := flag.NewFlagSet("list", flag.ContinueOnError)
|
|
fs.SetOutput(commandStderr)
|
|
setCommandUsage(fs, "list",
|
|
"List entries with optional repo, kind, and status filters.",
|
|
[]string{
|
|
"list is for broad inspection; use search when you need ranked text matching.",
|
|
"Filters are optional and can be combined to narrow the result set.",
|
|
},
|
|
`repo-memory list --db ~/.codex/data/repo-memory.db --repo zeus --kind term --status confirmed --limit 20`,
|
|
)
|
|
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
|
|
repo := fs.String("repo", "", "Optional repo path filter (substring match)")
|
|
kind := fs.String("kind", "", "Optional knowledge kind filter")
|
|
status := fs.String("status", "", "Optional status filter")
|
|
limit := fs.Int("limit", 20, "Result limit")
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
st, err := store.Open(*dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer st.Close()
|
|
|
|
items, err := st.ListEntries(context.Background(), store.ListParams{
|
|
RepoFilter: *repo,
|
|
Kind: *kind,
|
|
Status: *status,
|
|
Limit: *limit,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(items) == 0 {
|
|
_, _ = fmt.Fprintln(commandStdout, "no entries")
|
|
return nil
|
|
}
|
|
|
|
for _, item := range items {
|
|
_, _ = fmt.Fprintf(commandStdout, "- #%d [%s] %s:%s [%s]\n", item.ID, filepath.Base(item.RepoPath), item.Kind, item.Key, item.Status)
|
|
_, _ = fmt.Fprintf(commandStdout, " %s\n", item.Summary)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runEvents(args []string) error {
|
|
fs := flag.NewFlagSet("events", flag.ContinueOnError)
|
|
fs.SetOutput(commandStderr)
|
|
setCommandUsage(fs, "events",
|
|
"Show verification and status-change history for one entry.",
|
|
[]string{
|
|
"Resolve the entry either by --id or by the combination of --repo, --kind, and --key.",
|
|
"events is history-only; it does not modify entries.",
|
|
},
|
|
`repo-memory events --db ~/.codex/data/repo-memory.db --id 1`,
|
|
)
|
|
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
|
|
id := fs.Int64("id", 0, "Entry id")
|
|
repo := fs.String("repo", "", "Repo root when resolving by kind/key")
|
|
kind := fs.String("kind", "", "Knowledge kind when resolving by kind/key")
|
|
key := fs.String("key", "", "Knowledge key when resolving by kind/key")
|
|
limit := fs.Int("limit", 20, "Result limit")
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
st, err := store.Open(*dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer st.Close()
|
|
|
|
ref, err := buildEntryRef(*id, *repo, *kind, *key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry, err := st.ResolveEntry(context.Background(), ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
events, err := st.ListEvents(context.Background(), entry.ID, *limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(events) == 0 {
|
|
_, _ = fmt.Fprintln(commandStdout, "no events")
|
|
return nil
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(commandStdout, "%s:%s [%s] #%d\n", entry.Kind, entry.Key, entry.Status, entry.ID)
|
|
for _, ev := range events {
|
|
line := fmt.Sprintf("- %s %s", ev.CreatedAt.Format(time.RFC3339), ev.EventType)
|
|
if ev.FromStatus != "" || ev.ToStatus != "" {
|
|
line += fmt.Sprintf(" (%s -> %s)", emptyDash(ev.FromStatus), emptyDash(ev.ToStatus))
|
|
}
|
|
_, _ = fmt.Fprintln(commandStdout, line)
|
|
if ev.Reason != "" {
|
|
_, _ = fmt.Fprintf(commandStdout, " reason: %s\n", ev.Reason)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runLink(args []string) error {
|
|
fs := flag.NewFlagSet("link", flag.ContinueOnError)
|
|
fs.SetOutput(commandStderr)
|
|
setCommandUsage(fs, "link",
|
|
"Create a relationship edge between two stored entries.",
|
|
[]string{
|
|
"link expects two existing entry IDs.",
|
|
"Use links for durable relationships such as related_to, depends_on, or implements.",
|
|
},
|
|
`repo-memory link --db ~/.codex/data/repo-memory.db --from-id 1 --to-id 2 --relation related_to`,
|
|
)
|
|
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
|
|
fromID := fs.Int64("from-id", 0, "From entry id")
|
|
toID := fs.Int64("to-id", 0, "To entry id")
|
|
relation := fs.String("relation", "", "Link relation")
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
st, err := store.Open(*dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer st.Close()
|
|
|
|
if err := st.AddLink(context.Background(), *fromID, *toID, *relation); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(commandStdout, "linked #%d -[%s]-> #%d\n", *fromID, *relation, *toID)
|
|
return nil
|
|
}
|
|
|
|
func runVerify(args []string) error {
|
|
fs := flag.NewFlagSet("verify", flag.ContinueOnError)
|
|
fs.SetOutput(commandStderr)
|
|
setCommandUsage(fs, "verify",
|
|
"Re-check stored entries against current repository state and downgrade stale knowledge.",
|
|
[]string{
|
|
"verify uses current git state to detect changed or missing hard dependencies.",
|
|
"Pass --repo to verify one repository; omit it to verify every tracked repository in the database.",
|
|
},
|
|
`repo-memory verify --db ~/.codex/data/repo-memory.db --repo /path/to/repo`,
|
|
)
|
|
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
|
|
repo := fs.String("repo", "", "Optional repo root to verify; if omitted, verify all known repos")
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
st, err := store.Open(*dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer st.Close()
|
|
|
|
var repoRoots []string
|
|
if strings.TrimSpace(*repo) != "" {
|
|
absRepo, err := filepath.Abs(*repo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
repoRoots = []string{absRepo}
|
|
} else {
|
|
repos, err := st.ListRepos(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, repoInfo := range repos {
|
|
repoRoots = append(repoRoots, repoInfo.Path)
|
|
}
|
|
}
|
|
if len(repoRoots) == 0 {
|
|
_, _ = fmt.Fprintln(commandStdout, "no repos")
|
|
return nil
|
|
}
|
|
|
|
for _, repoRoot := range repoRoots {
|
|
gitState := detectGitState(repoRoot)
|
|
if gitState.commit == "" {
|
|
_, _ = fmt.Fprintf(commandStdout, "%s: skipped (not a git repo or no HEAD)\n", repoRoot)
|
|
continue
|
|
}
|
|
if _, err := st.UpsertRepo(context.Background(), store.RepoState{
|
|
RootPath: repoRoot,
|
|
Name: filepath.Base(repoRoot),
|
|
VCS: "git",
|
|
LastSeenBranch: gitState.branch,
|
|
LastSeenCommit: gitState.commit,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
candidates, err := st.ListVerifyCandidates(context.Background(), repoRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changedCount := 0
|
|
staleCount := 0
|
|
for _, candidate := range candidates {
|
|
nextStatus, reason, err := verifyCandidate(repoRoot, candidate)
|
|
if err != nil {
|
|
return fmt.Errorf("verify %s:%s: %w", candidate.Kind, candidate.Key, err)
|
|
}
|
|
if nextStatus != candidate.Status {
|
|
switch nextStatus {
|
|
case "needs_review":
|
|
changedCount++
|
|
case "stale":
|
|
staleCount++
|
|
}
|
|
}
|
|
if err := st.ApplyVerificationResult(context.Background(), candidate.ID, gitState.commit, nextStatus, reason); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, _ = fmt.Fprintf(commandStdout, "%s: verified %d entries, %d downgraded, %d stale\n", repoRoot, len(candidates), changedCount, staleCount)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func usage() {
|
|
_, _ = fmt.Fprintf(commandStderr, `repo-memory: repo memory CLI
|
|
|
|
Usage:
|
|
repo-memory <command> [flags]
|
|
|
|
Purpose:
|
|
Store durable repository knowledge in SQLite so agents can search prior findings,
|
|
ingest curated docs, add confirmed facts, inspect history, and verify stale entries.
|
|
|
|
Constraints:
|
|
- Write durable repository knowledge, not short-lived chat conclusions.
|
|
- Prefer search before add so repeated work starts from existing knowledge.
|
|
- Use verify when code has moved enough that stored entries may be stale.
|
|
|
|
Commands:
|
|
init Initialize or migrate one SQLite database
|
|
ingest Import markdown knowledge from docs under one repo
|
|
add Insert or update one durable knowledge entry
|
|
search Search stored knowledge by query text
|
|
list List entries with optional filters
|
|
events Show history for one entry
|
|
link Link two entries together
|
|
verify Re-check entries against current repo state
|
|
repos List tracked repositories
|
|
|
|
Examples:
|
|
repo-memory init --db ~/.codex/data/repo-memory.db
|
|
repo-memory search --db ~/.codex/data/repo-memory.db --repo zeus --query "router auth"
|
|
repo-memory add --db ~/.codex/data/repo-memory.db --repo /path/to/repo --kind term --key AuthRouter --summary "..."
|
|
repo-memory verify --db ~/.codex/data/repo-memory.db --repo /path/to/repo
|
|
|
|
More help:
|
|
repo-memory --help
|
|
repo-memory help add
|
|
repo-memory add --help
|
|
`)
|
|
}
|
|
|
|
func setCommandUsage(fs *flag.FlagSet, name, summary string, constraints []string, example string) {
|
|
fs.Usage = func() {
|
|
_, _ = fmt.Fprintf(commandStderr, "repo-memory %s\n\n", name)
|
|
_, _ = fmt.Fprintf(commandStderr, "Usage:\n repo-memory %s [flags]\n\n", name)
|
|
_, _ = fmt.Fprintf(commandStderr, "Purpose:\n %s\n\n", summary)
|
|
if len(constraints) > 0 {
|
|
_, _ = fmt.Fprintln(commandStderr, "Constraints:")
|
|
for _, constraint := range constraints {
|
|
if strings.TrimSpace(constraint) == "" {
|
|
continue
|
|
}
|
|
_, _ = fmt.Fprintf(commandStderr, " - %s\n", constraint)
|
|
}
|
|
_, _ = fmt.Fprintln(commandStderr)
|
|
}
|
|
if strings.TrimSpace(example) != "" {
|
|
_, _ = fmt.Fprintf(commandStderr, "Example:\n %s\n\n", example)
|
|
}
|
|
_, _ = fmt.Fprintln(commandStderr, "Flags:")
|
|
fs.PrintDefaults()
|
|
}
|
|
}
|
|
|
|
type gitState struct {
|
|
branch string
|
|
commit string
|
|
}
|
|
|
|
func detectGitState(repo string) gitState {
|
|
return gitState{
|
|
branch: strings.TrimSpace(run(repo, "git", "-C", repo, "rev-parse", "--abbrev-ref", "HEAD")),
|
|
commit: strings.TrimSpace(run(repo, "git", "-C", repo, "rev-parse", "HEAD")),
|
|
}
|
|
}
|
|
|
|
func run(dir string, name string, args ...string) string {
|
|
cmd := exec.Command(name, args...)
|
|
cmd.Dir = dir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func parseDeps(repoRoot string, values []string) []store.DependencyInput {
|
|
var deps []store.DependencyInput
|
|
for _, raw := range values {
|
|
parts := strings.Split(raw, ":")
|
|
if len(parts) < 2 {
|
|
continue
|
|
}
|
|
dep := store.DependencyInput{
|
|
Type: strings.TrimSpace(parts[0]),
|
|
Locator: strings.TrimSpace(parts[1]),
|
|
IsHard: true,
|
|
}
|
|
if dep.Type == "file" || dep.Type == "dir" || dep.Type == "glob" {
|
|
dep.Locator = relativize(repoRoot, dep.Locator)
|
|
}
|
|
if len(parts) >= 3 {
|
|
mode := strings.ToLower(strings.TrimSpace(parts[2]))
|
|
if mode == "soft" {
|
|
dep.IsHard = false
|
|
}
|
|
}
|
|
if dep.Type != "" && dep.Locator != "" {
|
|
deps = append(deps, dep)
|
|
}
|
|
}
|
|
return deps
|
|
}
|
|
|
|
func relativize(repoRoot, maybePath string) string {
|
|
if strings.TrimSpace(maybePath) == "" {
|
|
return ""
|
|
}
|
|
if !filepath.IsAbs(maybePath) {
|
|
return filepath.ToSlash(maybePath)
|
|
}
|
|
rel, err := filepath.Rel(repoRoot, maybePath)
|
|
if err != nil {
|
|
return filepath.ToSlash(maybePath)
|
|
}
|
|
if strings.HasPrefix(rel, "..") {
|
|
return filepath.ToSlash(maybePath)
|
|
}
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
|
|
func buildEntryRef(id int64, repo, kind, key string) (store.EntryRef, error) {
|
|
if id > 0 {
|
|
return store.EntryRef{ID: id}, nil
|
|
}
|
|
if strings.TrimSpace(repo) == "" || strings.TrimSpace(kind) == "" || strings.TrimSpace(key) == "" {
|
|
return store.EntryRef{}, fmt.Errorf("either --id or --repo+--kind+--key is required")
|
|
}
|
|
absRepo, err := filepath.Abs(repo)
|
|
if err != nil {
|
|
return store.EntryRef{}, err
|
|
}
|
|
return store.EntryRef{RepoRoot: absRepo, Kind: kind, Key: key}, nil
|
|
}
|
|
|
|
func verifyCandidate(repoRoot string, candidate store.VerifyCandidate) (string, string, error) {
|
|
if strings.TrimSpace(candidate.VerifiedOnCommit) == "" {
|
|
return "needs_review", "missing verified_on_commit", nil
|
|
}
|
|
|
|
var paths []string
|
|
for _, dep := range candidate.Dependencies {
|
|
if !dep.IsHard {
|
|
continue
|
|
}
|
|
switch dep.Type {
|
|
case "file", "dir", "glob":
|
|
paths = append(paths, dep.Locator)
|
|
if dep.Type == "file" {
|
|
absPath := dep.Locator
|
|
if !filepath.IsAbs(absPath) {
|
|
absPath = filepath.Join(repoRoot, dep.Locator)
|
|
}
|
|
if _, err := os.Stat(absPath); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "stale", "hard dependency missing: " + dep.Locator, nil
|
|
}
|
|
return candidate.Status, "", err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(paths) == 0 {
|
|
return candidate.Status, "", nil
|
|
}
|
|
|
|
changed, err := hasCommittedChanges(repoRoot, candidate.VerifiedOnCommit, paths)
|
|
if err != nil {
|
|
return candidate.Status, "", err
|
|
}
|
|
dirty, err := hasWorkingTreeChanges(repoRoot, paths)
|
|
if err != nil {
|
|
return candidate.Status, "", err
|
|
}
|
|
if changed || dirty {
|
|
return "needs_review", "dependency paths changed since verification", nil
|
|
}
|
|
return candidate.Status, "", nil
|
|
}
|
|
|
|
func hasCommittedChanges(repoRoot, fromCommit string, paths []string) (bool, error) {
|
|
args := append([]string{"-C", repoRoot, "diff", "--name-only", fromCommit + "..HEAD", "--"}, paths...)
|
|
out, err := exec.Command("git", args...).Output()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return strings.TrimSpace(string(out)) != "", nil
|
|
}
|
|
|
|
func hasWorkingTreeChanges(repoRoot string, paths []string) (bool, error) {
|
|
args := append([]string{"-C", repoRoot, "status", "--porcelain", "--"}, paths...)
|
|
out, err := exec.Command("git", args...).Output()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return strings.TrimSpace(string(out)) != "", nil
|
|
}
|
|
|
|
func emptyDash(value string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return "-"
|
|
}
|
|
return value
|
|
}
|