674 lines
17 KiB
Go
674 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"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() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
|
|
switch os.Args[1] {
|
|
case "init":
|
|
if err := runInit(os.Args[2:]); err != nil {
|
|
fatal(err)
|
|
}
|
|
case "add":
|
|
if err := runAdd(os.Args[2:]); err != nil {
|
|
fatal(err)
|
|
}
|
|
case "ingest":
|
|
if err := runIngest(os.Args[2:]); err != nil {
|
|
fatal(err)
|
|
}
|
|
case "search":
|
|
if err := runSearch(os.Args[2:]); err != nil {
|
|
fatal(err)
|
|
}
|
|
case "list":
|
|
if err := runList(os.Args[2:]); err != nil {
|
|
fatal(err)
|
|
}
|
|
case "events":
|
|
if err := runEvents(os.Args[2:]); err != nil {
|
|
fatal(err)
|
|
}
|
|
case "link":
|
|
if err := runLink(os.Args[2:]); err != nil {
|
|
fatal(err)
|
|
}
|
|
case "verify":
|
|
if err := runVerify(os.Args[2:]); err != nil {
|
|
fatal(err)
|
|
}
|
|
case "repos":
|
|
if err := runRepos(os.Args[2:]); err != nil {
|
|
fatal(err)
|
|
}
|
|
default:
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
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)
|
|
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
|
|
if err := fs.Parse(args); 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
|
|
}
|
|
|
|
fmt.Printf("initialized %s\n", *dbPath)
|
|
return nil
|
|
}
|
|
|
|
func runIngest(args []string) error {
|
|
fs := flag.NewFlagSet("ingest", flag.ContinueOnError)
|
|
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 {
|
|
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.Printf("ingested %d docs from %s\n", len(docs), absRepo)
|
|
return nil
|
|
}
|
|
|
|
func runAdd(args []string) error {
|
|
fs := flag.NewFlagSet("add", flag.ContinueOnError)
|
|
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 {
|
|
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.Printf("upserted entry %d (%s:%s)\n", entryID, input.Kind, input.Key)
|
|
return nil
|
|
}
|
|
|
|
func runSearch(args []string) error {
|
|
fs := flag.NewFlagSet("search", flag.ContinueOnError)
|
|
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 {
|
|
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.Println("no results")
|
|
return nil
|
|
}
|
|
|
|
for i, r := range results {
|
|
fmt.Printf("%d. [%s] %s:%s [%s]\n", i+1, filepath.Base(r.RepoPath), r.Kind, r.Key, r.Status)
|
|
fmt.Printf(" %s\n", r.Title)
|
|
fmt.Printf(" %s\n", r.Snippet)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runRepos(args []string) error {
|
|
fs := flag.NewFlagSet("repos", flag.ContinueOnError)
|
|
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
|
|
if err := fs.Parse(args); err != 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.Println("no repos")
|
|
return nil
|
|
}
|
|
|
|
for _, repo := range repos {
|
|
fmt.Printf("- %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)
|
|
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 {
|
|
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.Println("no entries")
|
|
return nil
|
|
}
|
|
|
|
for _, item := range items {
|
|
fmt.Printf("- #%d [%s] %s:%s [%s]\n", item.ID, filepath.Base(item.RepoPath), item.Kind, item.Key, item.Status)
|
|
fmt.Printf(" %s\n", item.Summary)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runEvents(args []string) error {
|
|
fs := flag.NewFlagSet("events", flag.ContinueOnError)
|
|
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 {
|
|
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.Println("no events")
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("%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.Println(line)
|
|
if ev.Reason != "" {
|
|
fmt.Printf(" reason: %s\n", ev.Reason)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runLink(args []string) error {
|
|
fs := flag.NewFlagSet("link", flag.ContinueOnError)
|
|
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 {
|
|
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.Printf("linked #%d -[%s]-> #%d\n", *fromID, *relation, *toID)
|
|
return nil
|
|
}
|
|
|
|
func runVerify(args []string) error {
|
|
fs := flag.NewFlagSet("verify", flag.ContinueOnError)
|
|
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 {
|
|
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.Println("no repos")
|
|
return nil
|
|
}
|
|
|
|
for _, repoRoot := range repoRoots {
|
|
gitState := detectGitState(repoRoot)
|
|
if gitState.commit == "" {
|
|
fmt.Printf("%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.Printf("%s: verified %d entries, %d downgraded, %d stale\n", repoRoot, len(candidates), changedCount, staleCount)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprintf(os.Stderr, `repo-memory: repo memory CLI
|
|
|
|
Usage:
|
|
repo-memory init --db repo-memory.db
|
|
repo-memory add --db repo-memory.db --repo /path/to/repo --kind term --key AITask --summary "..."
|
|
repo-memory ingest --db repo-memory.db --repo /path/to/repo [--path docs/ai]
|
|
repo-memory search --db repo-memory.db --query "actionCode fill" [--repo zeus]
|
|
repo-memory list --db repo-memory.db [--repo zeus] [--kind term] [--status confirmed]
|
|
repo-memory events --db repo-memory.db --id 1
|
|
repo-memory link --db repo-memory.db --from-id 1 --to-id 2 --relation related_to
|
|
repo-memory verify --db repo-memory.db [--repo /path/to/repo]
|
|
repo-memory repos --db repo-memory.db
|
|
`)
|
|
}
|
|
|
|
func fatal(err error) {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
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
|
|
}
|