Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions internal/scan/evidence.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package scan

import (
"context"
"fmt"
"os"
"sync"
"time"

"github.com/kryft-dev/cdd/internal/git"
"github.com/kryft-dev/cdd/internal/project"
)

// workers bounds the number of concurrent git calls a Scan makes while
// gathering each Project's evidence time, so scanning a large Root never
// spawns one git process per Project at once.
const workers = 8

// gatherEvidence returns each Project's evidence time, keyed by Rel: the
// Project's git.LastCommit time when it is a repository with a commit,
// else its directory's mtime. Git calls are bounded to workers at a time.
func gatherEvidence(ctx context.Context, root string, projects []project.Project) (map[string]time.Time, error) {
type result struct {
rel string
at time.Time
err error
}

sem := make(chan struct{}, workers)
results := make(chan result, len(projects))
var wg sync.WaitGroup

for _, p := range projects {
wg.Add(1)
go func(p project.Project) {
defer wg.Done()

sem <- struct{}{}
defer func() { <-sem }()

at, err := evidenceTime(ctx, p.Abs(root))
results <- result{rel: p.Rel(), at: at, err: err}
}(p)
}

go func() {
wg.Wait()
close(results)
}()

evidence := make(map[string]time.Time, len(projects))
for r := range results {
if r.err != nil {
return nil, fmt.Errorf("scan: evidence time for %q: %w", r.rel, r.err)
}
evidence[r.rel] = r.at
}
return evidence, nil
}

// evidenceTime returns dir's evidence time: its git.LastCommit time when
// dir is a repository with a commit, else dir's mtime. The result is
// truncated to UTC second precision, matching History's own line format, so
// repeated Scans compare equal rather than drifting on sub-second noise.
func evidenceTime(ctx context.Context, dir string) (time.Time, error) {
commit, ok, err := git.LastCommit(ctx, dir)
if err != nil {
return time.Time{}, err
}
if ok {
return commit.UTC().Truncate(time.Second), nil
}

info, err := os.Stat(dir)
if err != nil {
return time.Time{}, err
}
return info.ModTime().UTC().Truncate(time.Second), nil
}
76 changes: 76 additions & 0 deletions internal/scan/scan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Package scan discovers Projects under a Root and seeds History with one
// Visit per Project, so a fresh cdd installation has a starting order
// before any real Jump happens.
package scan

import (
"context"
"fmt"
"time"

"github.com/kryft-dev/cdd/internal/config"
"github.com/kryft-dev/cdd/internal/history"
"github.com/kryft-dev/cdd/internal/project"
)

// Summary reports what a Scan did, for the cli to print as "Seeded N
// Visits across M Projects".
type Summary struct {
// Seeded is the number of Visits actually appended to History.
Seeded int
// Projects is the number of Projects discovered under Root.
Projects int
}

// Run discovers Projects under cfg.Root, honoring cfg.Exclude and
// cfg.IncludeHidden, and seeds hist with one Visit per Project. Each
// Project's evidence time is git.LastCommit when the Project is a
// repository with a commit, else the Project directory's mtime.
//
// Whether a given Project actually gains a new Visit is entirely up to
// Seed's own idempotency rule (a Project with a newer real Visit is left
// alone); Run does not re-implement that rule, only reports how many
// Visits it produced.
func Run(ctx context.Context, cfg config.Config, hist *history.History) (Summary, error) {
projects, err := project.Discover(cfg.Root, cfg.Exclude, cfg.IncludeHidden)
if err != nil {
return Summary{}, fmt.Errorf("scan: discover projects: %w", err)
}

evidence, err := gatherEvidence(ctx, cfg.Root, projects)
if err != nil {
return Summary{}, err
}

summary := Summary{Projects: len(projects)}
for _, p := range projects {
seeded, err := seedOne(hist, p.Rel(), evidence[p.Rel()])
if err != nil {
return Summary{}, fmt.Errorf("scan: seed %q: %w", p.Rel(), err)
}
if seeded {
summary.Seeded++
}
}

return summary, nil
}

// seedOne calls Seed for project and reports whether it actually appended a
// Visit, by comparing History's Visit count for project before and after.
// This leans on history.Count rather than re-implementing Seed's
// idempotency rule.
func seedOne(hist *history.History, rel string, at time.Time) (bool, error) {
before, err := hist.Count(rel)
if err != nil {
return false, err
}
if err := hist.Seed(rel, at); err != nil {
return false, err
}
after, err := hist.Count(rel)
if err != nil {
return false, err
}
return after > before, nil
}
221 changes: 221 additions & 0 deletions internal/scan/scan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package scan_test

import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"

"github.com/kryft-dev/cdd/internal/config"
"github.com/kryft-dev/cdd/internal/git"
"github.com/kryft-dev/cdd/internal/history"
"github.com/kryft-dev/cdd/internal/scan"
)

// runGit runs a git command in dir with a test identity and no system or
// global config, failing the test on error.
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()

cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_CONFIG_NOSYSTEM=1",
"HOME=/nonexistent-cdd-test-home",
"GIT_AUTHOR_NAME=cdd test",
"GIT_AUTHOR_EMAIL=cdd-test@example.com",
"GIT_COMMITTER_NAME=cdd test",
"GIT_COMMITTER_EMAIL=cdd-test@example.com",
)

out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}

// newProjectDir creates a directory for a Project at kind/name under root.
func newProjectDir(t *testing.T, root, kind, name string) string {
t.Helper()

dir := filepath.Join(root, kind, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
return dir
}

// newRepoProjectDir creates a Project directory that is also a git
// repository with one commit, and returns its path.
func newRepoProjectDir(t *testing.T, root, kind, name string) string {
t.Helper()

dir := newProjectDir(t, root, kind, name)
runGit(t, dir, "init", "-q", "-b", "main")
runGit(t, dir, "config", "user.name", "cdd test")
runGit(t, dir, "config", "user.email", "cdd-test@example.com")
if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("hi"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
runGit(t, dir, "add", ".")
runGit(t, dir, "commit", "-q", "-m", "init")
return dir
}

// newHistory opens an empty History in a fresh temp directory.
func newHistory(t *testing.T) *history.History {
t.Helper()

path := filepath.Join(t.TempDir(), "history")
hist, err := history.Open(path, 1000)
if err != nil {
t.Fatalf("history.Open: %v", err)
}
return hist
}

func newConfig(root string, exclude []string) config.Config {
return config.Config{Root: root, Exclude: exclude}
}

func TestRun_RepositorySeededFromCommitTime(t *testing.T) {
root := t.TempDir()
dir := newRepoProjectDir(t, root, "tools", "cdd")

commitTime, ok, err := git.LastCommit(context.Background(), dir)
if err != nil || !ok {
t.Fatalf("git.LastCommit: ok=%v err=%v", ok, err)
}

hist := newHistory(t)
summary, err := scan.Run(context.Background(), newConfig(root, nil), hist)
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary != (scan.Summary{Seeded: 1, Projects: 1}) {
t.Fatalf("summary = %+v, want {Seeded:1 Projects:1}", summary)
}

visits, err := hist.Latest()
if err != nil {
t.Fatalf("Latest: %v", err)
}
if len(visits) != 1 {
t.Fatalf("len(visits) = %d, want 1", len(visits))
}
if visits[0].Project != "tools/cdd" {
t.Fatalf("project = %q, want tools/cdd", visits[0].Project)
}
if visits[0].Source != history.SourceScan {
t.Fatalf("source = %q, want scan", visits[0].Source)
}
if !visits[0].At.Equal(commitTime) {
t.Fatalf("At = %v, want %v", visits[0].At, commitTime)
}
}

func TestRun_NonRepositorySeededFromMtime(t *testing.T) {
root := t.TempDir()
dir := newProjectDir(t, root, "tools", "scratch")

info, err := os.Stat(dir)
if err != nil {
t.Fatalf("Stat: %v", err)
}

hist := newHistory(t)
summary, err := scan.Run(context.Background(), newConfig(root, nil), hist)
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Seeded != 1 {
t.Fatalf("Seeded = %d, want 1", summary.Seeded)
}

visits, err := hist.Latest()
if err != nil {
t.Fatalf("Latest: %v", err)
}
if len(visits) != 1 {
t.Fatalf("len(visits) = %d, want 1", len(visits))
}
want := info.ModTime().UTC().Truncate(time.Second)
if !visits[0].At.Equal(want) {
t.Fatalf("At = %v, want %v", visits[0].At, want)
}
}

func TestRun_AlreadyJumpedProjectNotOverwritten(t *testing.T) {
root := t.TempDir()
newProjectDir(t, root, "tools", "cdd")

hist := newHistory(t)
if err := hist.Record("tools/cdd"); err != nil {
t.Fatalf("Record: %v", err)
}
before, err := hist.Latest()
if err != nil {
t.Fatalf("Latest: %v", err)
}

summary, err := scan.Run(context.Background(), newConfig(root, nil), hist)
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Seeded != 0 {
t.Fatalf("Seeded = %d, want 0", summary.Seeded)
}

after, err := hist.Latest()
if err != nil {
t.Fatalf("Latest: %v", err)
}
if len(after) != 1 || after[0].Source != history.SourceJump || !after[0].At.Equal(before[0].At) {
t.Fatalf("after = %+v, want unchanged jump visit %+v", after, before)
}
}

func TestRun_SecondRunSeedsNothingNew(t *testing.T) {
root := t.TempDir()
newProjectDir(t, root, "tools", "cdd")

hist := newHistory(t)
cfg := newConfig(root, nil)

if _, err := scan.Run(context.Background(), cfg, hist); err != nil {
t.Fatalf("first Run: %v", err)
}

summary, err := scan.Run(context.Background(), cfg, hist)
if err != nil {
t.Fatalf("second Run: %v", err)
}
if summary != (scan.Summary{Seeded: 0, Projects: 1}) {
t.Fatalf("summary = %+v, want {Seeded:0 Projects:1}", summary)
}
}

func TestRun_ExcludedProjectSkipped(t *testing.T) {
root := t.TempDir()
newProjectDir(t, root, "tools", "cdd")
newProjectDir(t, root, "tools", "scratch")

hist := newHistory(t)
summary, err := scan.Run(context.Background(), newConfig(root, []string{"tools/scratch"}), hist)
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary != (scan.Summary{Seeded: 1, Projects: 1}) {
t.Fatalf("summary = %+v, want {Seeded:1 Projects:1}", summary)
}

visits, err := hist.Latest()
if err != nil {
t.Fatalf("Latest: %v", err)
}
if len(visits) != 1 || visits[0].Project != "tools/cdd" {
t.Fatalf("visits = %+v, want only tools/cdd", visits)
}
}