686 lines
18 KiB
Go
686 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// migrateRepositories moves existing top-level directories into the "repositories" folder.
|
|
// It skips specific reserved directories and configuration files.
|
|
func migrateRepositories(root string) {
|
|
reposDir := filepath.Join(root, "repositories")
|
|
if _, err := os.Stat(reposDir); os.IsNotExist(err) {
|
|
os.MkdirAll(reposDir, 0755)
|
|
}
|
|
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
exclusions := map[string]bool{
|
|
"web": true,
|
|
"repositories": true,
|
|
".git": true,
|
|
".agent": true,
|
|
".gemini": true,
|
|
"tmp": true,
|
|
"portagit.json": true,
|
|
"main.go": true,
|
|
"go.mod": true,
|
|
"go.sum": true,
|
|
"types.go": true,
|
|
"git_utils.go": true,
|
|
"config_utils.go": true,
|
|
}
|
|
|
|
// Define a list of reserved file and directory names that should be excluded from the migration process.
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
name := e.Name()
|
|
if exclusions[name] {
|
|
continue
|
|
}
|
|
|
|
oldPath := filepath.Join(root, name)
|
|
if _, err := os.Stat(filepath.Join(oldPath, ".git")); err == nil {
|
|
newPath := filepath.Join(reposDir, name)
|
|
fmt.Printf("Migrating repository %s to %s...\n", name, newPath)
|
|
os.Rename(oldPath, newPath)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// scanRepositories iterates through the repositories folder and collects metadata
|
|
// for each git repository found (name, description, language, update time).
|
|
// It utilizes concurrent goroutines to optimize the scanning performance.
|
|
func scanRepositories(root string) []Repository {
|
|
var repos []Repository
|
|
var mu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return repos
|
|
}
|
|
|
|
sem := make(chan struct{}, 20)
|
|
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
wg.Add(1)
|
|
go func(e os.DirEntry) {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
|
|
repoPath := filepath.Join(root, e.Name())
|
|
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
|
|
|
|
description := ""
|
|
if descBytes, err := os.ReadFile(filepath.Join(repoPath, ".git", "description")); err == nil {
|
|
desc := strings.TrimSpace(string(descBytes))
|
|
if desc != "" && !strings.Contains(desc, "Unnamed repository") {
|
|
description = desc
|
|
}
|
|
}
|
|
|
|
updated := "Unknown"
|
|
cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--format=%ar")
|
|
if out, err := cmd.Output(); err == nil {
|
|
updated = strings.TrimSpace(string(out))
|
|
} else {
|
|
if info, err := e.Info(); err == nil {
|
|
updated = info.ModTime().Format("Jan 02, 2006")
|
|
}
|
|
}
|
|
|
|
// Determine the primary programming language by analyzing file extensions within the repository.
|
|
language := "Unknown"
|
|
extCounts := make(map[string]int)
|
|
|
|
// Recursively walk through the repository directory tree to count file extensions.
|
|
filepath.WalkDir(repoPath, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
if d.Name() == ".git" || d.Name() == "node_modules" || d.Name() == "vendor" || d.Name() == "dist" || d.Name() == "build" || d.Name() == ".idea" || d.Name() == ".vscode" || d.Name() == "bin" || d.Name() == "obj" {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(d.Name()))
|
|
switch ext {
|
|
case ".go":
|
|
extCounts["Go"]++
|
|
case ".py":
|
|
extCounts["Python"]++
|
|
case ".js", ".mjs", ".jsx":
|
|
extCounts["JavaScript"]++
|
|
case ".ts", ".tsx":
|
|
extCounts["TypeScript"]++
|
|
case ".html", ".htm":
|
|
extCounts["HTML"]++
|
|
case ".css", ".scss", ".less":
|
|
extCounts["CSS"]++
|
|
case ".java":
|
|
extCounts["Java"]++
|
|
case ".c", ".h":
|
|
extCounts["C"]++
|
|
case ".cpp", ".hpp", ".cc":
|
|
extCounts["C++"]++
|
|
case ".php":
|
|
extCounts["PHP"]++
|
|
case ".rb":
|
|
extCounts["Ruby"]++
|
|
case ".rs":
|
|
extCounts["Rust"]++
|
|
case ".cs":
|
|
extCounts["C#"]++
|
|
case ".swift":
|
|
extCounts["Swift"]++
|
|
case ".kt", ".kts":
|
|
extCounts["Kotlin"]++
|
|
case ".dart":
|
|
extCounts["Dart"]++
|
|
case ".lua":
|
|
extCounts["Lua"]++
|
|
case ".sh", ".bash", ".zsh":
|
|
extCounts["Shell"]++
|
|
}
|
|
return nil
|
|
})
|
|
|
|
maxCount := 0
|
|
for lang, count := range extCounts {
|
|
if count > maxCount {
|
|
maxCount = count
|
|
language = lang
|
|
}
|
|
}
|
|
|
|
mu.Lock()
|
|
repos = append(repos, Repository{
|
|
Name: e.Name(),
|
|
Description: description,
|
|
Language: language,
|
|
Path: e.Name(),
|
|
UpdatedAt: updated,
|
|
})
|
|
mu.Unlock()
|
|
}
|
|
}(e)
|
|
}
|
|
}
|
|
wg.Wait()
|
|
|
|
sort.Slice(repos, func(i, j int) bool {
|
|
return repos[i].Name < repos[j].Name
|
|
})
|
|
|
|
return repos
|
|
}
|
|
|
|
// getBranches retrieves all branches for a given repository and identifies the current active branch.
|
|
func getBranches(repoPath string) ([]string, string) {
|
|
var branches []string
|
|
var current string
|
|
|
|
cmd := exec.Command("git", "-C", repoPath, "branch", "-a")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return branches, ""
|
|
}
|
|
|
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "* ") {
|
|
current = strings.TrimPrefix(line, "* ")
|
|
branches = append(branches, current)
|
|
} else {
|
|
cleanName := strings.TrimSpace(line)
|
|
if strings.HasPrefix(cleanName, "remotes/origin/") {
|
|
cleanName = strings.TrimPrefix(cleanName, "remotes/origin/")
|
|
if cleanName == "HEAD" || strings.HasPrefix(cleanName, "HEAD ->") {
|
|
continue
|
|
}
|
|
}
|
|
// Avoid duplicates if a branch exists both locally and remotely
|
|
duplicate := false
|
|
for _, b := range branches {
|
|
if b == cleanName {
|
|
duplicate = true
|
|
break
|
|
}
|
|
}
|
|
if !duplicate {
|
|
branches = append(branches, cleanName)
|
|
}
|
|
}
|
|
}
|
|
return branches, current
|
|
}
|
|
|
|
// createBranch creates a new branch in the specified repository.
|
|
func createBranch(repoPath, branchName string) error {
|
|
cmd := exec.Command("git", "-C", repoPath, "branch", branchName)
|
|
return cmd.Run()
|
|
}
|
|
|
|
// getCommits fetches specific metadata for the latest 20 commits of the repository.
|
|
func getCommits(repoPath string) []Commit {
|
|
var commits []Commit
|
|
cmd := exec.Command("git", "-C", repoPath, "log", "--all", "--pretty=format:%h|%s|%an|%ar", "-n", "20")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return commits
|
|
}
|
|
|
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
for scanner.Scan() {
|
|
parts := strings.Split(scanner.Text(), "|")
|
|
if len(parts) >= 4 {
|
|
commits = append(commits, Commit{
|
|
Hash: parts[0],
|
|
Message: parts[1],
|
|
Author: parts[2],
|
|
Date: parts[3],
|
|
})
|
|
}
|
|
}
|
|
return commits
|
|
}
|
|
|
|
// getCommitsForMonth fetches all commits within a specific month and year.
|
|
func getCommitsForMonth(repoPath string, year, month int) []Commit {
|
|
var commits []Commit
|
|
|
|
start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
|
|
end := start.AddDate(0, 1, 0)
|
|
|
|
since := start.Format("2006-01-02")
|
|
until := end.Format("2006-01-02")
|
|
|
|
cmd := exec.Command("git", "-C", repoPath, "log", "--all",
|
|
fmt.Sprintf("--since=%s", since),
|
|
fmt.Sprintf("--until=%s", until),
|
|
"--pretty=format:%h|%s|%an|%ar|%ad",
|
|
"--date=short",
|
|
)
|
|
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
fmt.Printf("Error getting commits for month: %v\n", err)
|
|
return commits
|
|
}
|
|
|
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
for scanner.Scan() {
|
|
parts := strings.Split(scanner.Text(), "|")
|
|
if len(parts) >= 4 {
|
|
c := Commit{
|
|
Hash: parts[0],
|
|
Message: parts[1],
|
|
Author: parts[2],
|
|
Date: parts[3],
|
|
}
|
|
|
|
commits = append(commits, c)
|
|
}
|
|
}
|
|
return commits
|
|
}
|
|
|
|
// getCommitDiff retrieves the file changes (diffs) for a specific commit hash.
|
|
func getCommitDiff(repoPath, hash string) []FileDiff {
|
|
cmd := exec.Command("git", "-C", repoPath, "show", hash, "--name-only", "--format=")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
files := strings.Split(strings.TrimSpace(string(out)), "\n")
|
|
var diffs []FileDiff
|
|
|
|
for _, file := range files {
|
|
if file == "" {
|
|
continue
|
|
}
|
|
diffCmd := exec.Command("git", "-C", repoPath, "show", hash, "--", file)
|
|
diffOut, _ := diffCmd.Output()
|
|
diffs = append(diffs, FileDiff{
|
|
Name: file,
|
|
Content: string(diffOut),
|
|
})
|
|
}
|
|
return diffs
|
|
}
|
|
|
|
// getAvailableYears scans all repositories to find years that have commit activity matching the user's identity.
|
|
func getAvailableYears(root string, authorPatterns []string) []int {
|
|
yearsMap := make(map[int]bool)
|
|
var mu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return []int{time.Now().Year()}
|
|
}
|
|
|
|
sem := make(chan struct{}, 20)
|
|
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
wg.Add(1)
|
|
go func(e os.DirEntry) {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
|
|
repoPath := filepath.Join(root, e.Name())
|
|
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
|
|
args := []string{"-C", repoPath, "log", "--all", "--format=%ad", "--date=format:%Y"}
|
|
for _, author := range authorPatterns {
|
|
if author != "" {
|
|
args = append(args, "--author="+author)
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command("git", args...)
|
|
out, err := cmd.Output()
|
|
if err == nil {
|
|
localYears := make(map[int]bool)
|
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
for scanner.Scan() {
|
|
yStr := strings.TrimSpace(scanner.Text())
|
|
if y, err := strconv.Atoi(yStr); err == nil {
|
|
localYears[y] = true
|
|
}
|
|
}
|
|
|
|
mu.Lock()
|
|
for y := range localYears {
|
|
yearsMap[y] = true
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
}(e)
|
|
}
|
|
}
|
|
wg.Wait()
|
|
|
|
var years []int
|
|
for y := range yearsMap {
|
|
years = append(years, y)
|
|
}
|
|
if len(years) == 0 {
|
|
years = append(years, time.Now().Year())
|
|
}
|
|
sort.Sort(sort.Reverse(sort.IntSlice(years)))
|
|
return years
|
|
}
|
|
|
|
// scanCommitsForYear aggregates commit activity for a given year across all repositories.
|
|
// It returns data suitable for populating the contribution graph and the activity feed.
|
|
func scanCommitsForYear(root string, year int, authorPatterns []string) ([]ContributionDay, []ActivityMonth) {
|
|
commitCounts := make(map[string]int)
|
|
repoMonthlyActivity := make(map[string]map[string]int)
|
|
|
|
var mu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return []ContributionDay{}, []ActivityMonth{}
|
|
}
|
|
|
|
sem := make(chan struct{}, 20)
|
|
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
wg.Add(1)
|
|
go func(e os.DirEntry) {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
|
|
repoPath := filepath.Join(root, e.Name())
|
|
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
|
|
args := []string{"-C", repoPath, "log", "--all", "--format=%ad", "--date=short"}
|
|
for _, author := range authorPatterns {
|
|
if author != "" {
|
|
args = append(args, "--author="+author)
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command("git", args...)
|
|
out, err := cmd.Output()
|
|
if err == nil {
|
|
localCounts := make(map[string]int)
|
|
localMonthly := make(map[string]int)
|
|
|
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
for scanner.Scan() {
|
|
date := strings.TrimSpace(scanner.Text())
|
|
if strings.HasPrefix(date, fmt.Sprintf("%d-", year)) {
|
|
localCounts[date]++
|
|
|
|
t, _ := time.Parse("2006-01-02", date)
|
|
monthKey := t.Format("January 2006")
|
|
localMonthly[monthKey]++
|
|
}
|
|
}
|
|
|
|
mu.Lock()
|
|
for d, c := range localCounts {
|
|
commitCounts[d] += c
|
|
}
|
|
for m, c := range localMonthly {
|
|
if repoMonthlyActivity[m] == nil {
|
|
repoMonthlyActivity[m] = make(map[string]int)
|
|
}
|
|
repoMonthlyActivity[m][e.Name()] += c
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
}(e)
|
|
}
|
|
}
|
|
wg.Wait()
|
|
|
|
wg.Wait()
|
|
|
|
// Initialize the contribution graph grid, accounting for leap years and weekly offset.
|
|
var graph []ContributionDay
|
|
startDate := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
endDate := time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC)
|
|
|
|
offset := int(startDate.Weekday())
|
|
for i := 0; i < offset; i++ {
|
|
graph = append(graph, ContributionDay{
|
|
Date: "",
|
|
Count: 0,
|
|
Color: "contrib-empty",
|
|
})
|
|
}
|
|
|
|
for d := startDate; !d.After(endDate); d = d.AddDate(0, 0, 1) {
|
|
// Calculate the contribution level (color intensity) based on the number of commits for the day.
|
|
dateStr := d.Format("2006-01-02")
|
|
count := commitCounts[dateStr]
|
|
|
|
level := 0
|
|
if count > 0 {
|
|
level = 1
|
|
}
|
|
if count >= 3 {
|
|
level = 2
|
|
}
|
|
if count >= 6 {
|
|
level = 3
|
|
}
|
|
if count >= 10 {
|
|
level = 4
|
|
}
|
|
|
|
colorClass := fmt.Sprintf("contrib-level-%d", level)
|
|
|
|
graph = append(graph, ContributionDay{
|
|
Date: dateStr,
|
|
Count: count,
|
|
Color: colorClass,
|
|
})
|
|
}
|
|
|
|
var activityFeed []ActivityMonth
|
|
|
|
for m := 12; m >= 1; m-- {
|
|
t := time.Date(year, time.Month(m), 1, 0, 0, 0, 0, time.UTC)
|
|
monthKey := t.Format("January 2006")
|
|
|
|
if repos, ok := repoMonthlyActivity[monthKey]; ok {
|
|
var items []ActivityItem
|
|
totalCommitsInMonth := 0
|
|
for repoName, count := range repos {
|
|
items = append(items, ActivityItem{
|
|
RepoName: repoName,
|
|
Commits: count,
|
|
})
|
|
totalCommitsInMonth += count
|
|
}
|
|
sort.Slice(items, func(i, j int) bool {
|
|
return items[i].Commits > items[j].Commits
|
|
})
|
|
|
|
activityFeed = append(activityFeed, ActivityMonth{
|
|
MonthName: monthKey,
|
|
YearInt: year,
|
|
MonthInt: m,
|
|
TotalCommits: totalCommitsInMonth,
|
|
RepoCount: len(items),
|
|
Items: items,
|
|
})
|
|
}
|
|
}
|
|
|
|
return graph, activityFeed
|
|
}
|
|
|
|
// scanActivityForDate retrieves detailed activity (list of repositories and commit counts) for a specific date.
|
|
func scanActivityForDate(scanPath string, dateStr string, authorPatterns []string) []ActivityMonth {
|
|
var activityFeed []ActivityMonth
|
|
|
|
t, err := time.Parse("2006-01-02", dateStr)
|
|
if err != nil {
|
|
return activityFeed
|
|
}
|
|
|
|
entries, err := os.ReadDir(scanPath)
|
|
if err != nil {
|
|
return activityFeed
|
|
}
|
|
|
|
reposMap := make(map[string]int)
|
|
var mu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
sem := make(chan struct{}, 20)
|
|
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
wg.Add(1)
|
|
go func(repoName string) {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
|
|
repoPath := filepath.Join(scanPath, repoName)
|
|
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err != nil {
|
|
return
|
|
}
|
|
|
|
args := []string{"-C", repoPath, "log", "--all", "--format=%an|%ae|%ad", "--date=short"}
|
|
|
|
cmd := exec.Command("git", args...)
|
|
out, err := cmd.Output()
|
|
if err == nil && len(out) > 0 {
|
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
count := 0
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 3 {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(parts[0])
|
|
email := strings.TrimSpace(parts[1])
|
|
date := strings.TrimSpace(parts[2])
|
|
|
|
if date != dateStr {
|
|
continue
|
|
}
|
|
|
|
match := false
|
|
if len(authorPatterns) == 0 {
|
|
match = true
|
|
} else {
|
|
for _, p := range authorPatterns {
|
|
if strings.EqualFold(p, name) || strings.EqualFold(p, email) {
|
|
match = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if match {
|
|
count++
|
|
}
|
|
}
|
|
|
|
if count > 0 {
|
|
mu.Lock()
|
|
reposMap[repoName] = count
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
}(e.Name())
|
|
}
|
|
}
|
|
wg.Wait()
|
|
|
|
if len(reposMap) > 0 {
|
|
var items []ActivityItem
|
|
total := 0
|
|
for name, count := range reposMap {
|
|
items = append(items, ActivityItem{RepoName: name, Commits: count})
|
|
total += count
|
|
}
|
|
sort.Slice(items, func(i, j int) bool {
|
|
return items[i].Commits > items[j].Commits
|
|
})
|
|
|
|
title := fmt.Sprintf("Activity on %s", t.Format("Jan 02, 2006"))
|
|
|
|
activityFeed = append(activityFeed, ActivityMonth{
|
|
MonthName: title,
|
|
TotalCommits: total,
|
|
RepoCount: len(items),
|
|
Items: items,
|
|
YearInt: t.Year(),
|
|
MonthInt: int(t.Month()),
|
|
Date: dateStr,
|
|
})
|
|
}
|
|
|
|
return activityFeed
|
|
}
|
|
|
|
// getCommitsForDate retrieves all commits made on a specific date in a given repository.
|
|
func getCommitsForDate(repoPath string, dateStr string) []Commit {
|
|
var commits []Commit
|
|
|
|
args := []string{"-C", repoPath, "log", "--all", "--format=%h|%s|%an|%ar|%ad", "--date=short"}
|
|
|
|
cmd := exec.Command("git", args...)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return commits
|
|
}
|
|
|
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 5 {
|
|
continue
|
|
}
|
|
|
|
cDate := strings.TrimSpace(parts[4])
|
|
|
|
if cDate == dateStr {
|
|
commits = append(commits, Commit{
|
|
Hash: parts[0],
|
|
Message: parts[1],
|
|
Author: parts[2],
|
|
Date: parts[3],
|
|
})
|
|
}
|
|
}
|
|
return commits
|
|
}
|