2c0f8fa257
Refs #1 - F1: canonical fingerprints (git identity by remote URL, log by basename, obsidian by relative vault path) so identical facts get identical ids across machines - F2: node_id (persisted in settings) + Hybrid Logical Clock in all observation "when" columns; removed time.Now() fact-time fallbacks - F3: stable TF-IDF tie-break sort (score desc, term asc) - F4: observations carry (node_id, hcl) locator; dedup ordered by hcl
270 lines
7.6 KiB
Go
270 lines
7.6 KiB
Go
package ingest
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// GitIngester tracks project status for local git repositories.
|
|
//
|
|
// For each repo under one or more scan roots (default ~/src) it records the
|
|
// current branch, whether the working tree is clean, ahead/behind counts vs
|
|
// the upstream, the HEAD hash, and the last commit time + subject. The entry's
|
|
// CreatedAt is the last commit time, so staleness/dedup works naturally and
|
|
// an entry updates when new commits land.
|
|
//
|
|
// When Recursive is set, repos are discovered at any depth under a root
|
|
// (following symlinked directories), not just direct children — this picks up
|
|
// nested repos like ~/assistant/kitmaker or ~/Arduino/*/... Repos already have
|
|
// a stable identity via their real (symlink-resolved) path, so overlapping
|
|
// roots across symlinks do not duplicate entries.
|
|
type GitIngester struct {
|
|
Version string
|
|
Root string // single scan root; overridden by Roots (empty => $HOME/src)
|
|
Roots []string // multi-scan roots; overrides Root when non-empty
|
|
Recursive bool // recurse into subdirs (and follow dir symlinks) to find nested .git
|
|
}
|
|
|
|
func NewGitIngester() *GitIngester {
|
|
return &GitIngester{Version: "git/v1"}
|
|
}
|
|
|
|
func (g *GitIngester) SourceID() string { return "git" }
|
|
|
|
// scanRoots returns the effective list of scan roots.
|
|
func (g *GitIngester) scanRoots() []string {
|
|
if len(g.Roots) > 0 {
|
|
return g.Roots
|
|
}
|
|
if g.Root != "" {
|
|
return []string{g.Root}
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return []string{filepath.Join(home, "src")}
|
|
}
|
|
|
|
func (g *GitIngester) IngestAll() ([]*IngestResult, error) {
|
|
// Dedup by real path so overlapping roots / symlinked roots don't double-add.
|
|
seen := make(map[string]struct{})
|
|
var results []*IngestResult
|
|
for _, root := range g.scanRoots() {
|
|
for _, repoPath := range collectGitRepos(root, g.Recursive) {
|
|
real, err := filepath.EvalSymlinks(repoPath)
|
|
if err != nil {
|
|
real = repoPath
|
|
}
|
|
if _, dup := seen[real]; dup {
|
|
continue
|
|
}
|
|
seen[real] = struct{}{}
|
|
if r := g.repoToResult(real); r != nil {
|
|
results = append(results, r)
|
|
}
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func isGitRepo(path string) bool {
|
|
info, err := os.Stat(filepath.Join(path, ".git"))
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
// collectGitRepos returns paths of git repos under root. In non-recursive mode
|
|
// only direct children are considered (root itself is not counted), matching
|
|
// the original behavior. In recursive mode the root is walked to any depth,
|
|
// descending into symlinked directories, up to and including the root if it is
|
|
// itself a git repo. Descending stops once a git repo is found, so nested
|
|
// repos (e.g. a vendored copy inside another repo) are treated as leaves.
|
|
func collectGitRepos(root string, recursive bool) []string {
|
|
visited := make(map[string]struct{})
|
|
var out []string
|
|
|
|
var walk func(dir string)
|
|
walk = func(dir string) {
|
|
real, err := filepath.EvalSymlinks(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if _, ok := visited[real]; ok {
|
|
return
|
|
}
|
|
visited[real] = struct{}{}
|
|
|
|
if isGitRepo(real) {
|
|
out = append(out, real)
|
|
return
|
|
}
|
|
|
|
entries, err := os.ReadDir(real)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), ".") {
|
|
continue
|
|
}
|
|
child := filepath.Join(real, e.Name())
|
|
if recursive {
|
|
walk(child)
|
|
continue
|
|
}
|
|
if e.IsDir() && isGitRepo(child) {
|
|
out = append(out, child)
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(root)
|
|
return out
|
|
}
|
|
|
|
type GitStatus struct {
|
|
Branch string
|
|
Head string
|
|
Dirty int
|
|
Ahead int
|
|
Behind int
|
|
LastCommit string
|
|
Subject string
|
|
}
|
|
|
|
func (g *GitIngester) repoToResult(repoPath string) *IngestResult {
|
|
status := gitStatus(repoPath)
|
|
name := filepath.Base(repoPath)
|
|
fp := Fingerprint([]byte("git:repo:" + gitIdentity(repoPath, status.Branch)))
|
|
|
|
clean := "clean"
|
|
if status.Dirty > 0 {
|
|
clean = fmt.Sprintf("%d change(s)", status.Dirty)
|
|
}
|
|
|
|
summary := fmt.Sprintf("%s @ %s — %s", name, status.Branch, clean)
|
|
if status.Ahead > 0 || status.Behind > 0 {
|
|
summary += fmt.Sprintf(", ahead %d / behind %d", status.Ahead, status.Behind)
|
|
}
|
|
if status.Subject != "" {
|
|
summary += " — “" + truncate(status.Subject, 80) + "”"
|
|
}
|
|
|
|
createdAt := status.LastCommit
|
|
|
|
return &IngestResult{
|
|
Fingerprint: fp,
|
|
SourceID: g.SourceID(),
|
|
SourcePath: repoPath,
|
|
Project: name,
|
|
ContentType: "git-status",
|
|
Title: name,
|
|
Summary: summary,
|
|
CreatedAt: createdAt,
|
|
Confidence: 0.9,
|
|
IngesterVersion: g.Version,
|
|
Provenance: map[string]any{
|
|
"type": "git_status",
|
|
"path": repoPath,
|
|
"branch": status.Branch,
|
|
"head": status.Head,
|
|
"dirty": status.Dirty,
|
|
"ahead": status.Ahead,
|
|
"behind": status.Behind,
|
|
"last_commit": status.LastCommit,
|
|
"subject": status.Subject,
|
|
},
|
|
}
|
|
}
|
|
|
|
func gitStatus(repoPath string) GitStatus {
|
|
out, err := gitOut(repoPath, "status", "--porcelain")
|
|
if err != nil {
|
|
return GitStatus{}
|
|
}
|
|
dirty := 0
|
|
if out != "" {
|
|
dirty = len(strings.Split(strings.TrimRight(out, "\n"), "\n"))
|
|
}
|
|
|
|
branch, _ := gitOut(repoPath, "rev-parse", "--abbrev-ref", "HEAD")
|
|
head, _ := gitOut(repoPath, "rev-parse", "--short", "HEAD")
|
|
lastCommit, _ := gitOut(repoPath, "log", "-1", "--format=%cI")
|
|
subject, _ := gitOut(repoPath, "log", "-1", "--format=%s")
|
|
|
|
ahead, behind := 0, 0
|
|
// ahead/behind only meaningful if there's an upstream
|
|
if upOut, _ := gitOut(repoPath, "rev-parse", "--abbrev-ref", "HEAD@{upstream}"); upOut != "" {
|
|
if rev, err := gitOut(repoPath, "rev-list", "--left-right", "--count", "HEAD...@{upstream}"); err == nil {
|
|
fields := strings.Fields(rev)
|
|
if len(fields) == 2 {
|
|
var a, b int
|
|
if _, e1 := fmt.Sscanf(fields[0], "%d", &a); e1 == nil {
|
|
ahead = a
|
|
}
|
|
if _, e2 := fmt.Sscanf(fields[1], "%d", &b); e2 == nil {
|
|
behind = b
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return GitStatus{
|
|
Branch: strings.TrimSpace(branch),
|
|
Head: strings.TrimSpace(head),
|
|
Dirty: dirty,
|
|
Ahead: ahead,
|
|
Behind: behind,
|
|
LastCommit: strings.TrimSpace(lastCommit),
|
|
Subject: strings.TrimSpace(subject),
|
|
}
|
|
}
|
|
|
|
// sanitizeGitField strips ANSI codes and control bytes from git output
|
|
// (colour/unicode modes can inject escape sequences).
|
|
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
|
|
|
func sanitizeGitField(s string) string {
|
|
s = ansiRe.ReplaceAllString(s, "")
|
|
s = strings.ReplaceAll(s, "\x1b", "")
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// gitOut runs git in repoPath and returns trimmed stdout (or "" on error).
|
|
func gitOut(repoPath string, args ...string) (string, error) {
|
|
cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...)
|
|
cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return sanitizeGitField(string(out)), nil
|
|
}
|
|
|
|
// gitIdentity returns a machine-independent identity for a repo: the remote
|
|
// origin URL when configured (works across clones/machines), else the repo
|
|
// directory name. The branch is appended so entries are per-branch like the
|
|
// underlying gitStatus.
|
|
func gitIdentity(repoPath, branch string) string {
|
|
if out, err := gitOut(repoPath, "remote", "get-url", "origin"); err == nil && out != "" {
|
|
return out + ":" + branch
|
|
}
|
|
return filepath.Base(repoPath) + ":" + branch
|
|
}
|
|
|
|
func (g *GitIngester) Ingest(_ string) (*IngestResult, error) {
|
|
return nil, fmt.Errorf("use IngestAll() for git")
|
|
}
|
|
|
|
var _ Ingester = (*GitIngester)(nil)
|
|
|
|
// SortResults orders results deterministically by project name (helper for CLI output).
|
|
func SortResults(res []*IngestResult) {
|
|
sort.Slice(res, func(i, j int) bool { return res[i].Project < res[j].Project })
|
|
}
|