From 877280aa229f17a5c3238373a67919cc77c90c15 Mon Sep 17 00:00:00 2001 From: RainbowYoshi Date: Sat, 31 Jan 2026 21:51:49 +0000 Subject: [PATCH] =?UTF-8?q?T=C3=A9l=C3=A9verser=20les=20fichiers=20vers=20?= =?UTF-8?q?"/"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 15 ++ config_utils.go | 67 +++++ git_utils.go | 685 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 767 insertions(+) create mode 100644 .gitignore create mode 100644 config_utils.go create mode 100644 git_utils.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54d54bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Binaries +PortaGit.exe +portagit +*.exe +*.dll + +# Application Data +repositories/ +uploads/ +portagit.json +*.db + +# OS specific checks +.DS_Store +thumbs.db diff --git a/config_utils.go b/config_utils.go new file mode 100644 index 0000000..dce6825 --- /dev/null +++ b/config_utils.go @@ -0,0 +1,67 @@ +package main + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" +) + +// loadConfig reads the configuration file from the specified working directory. +// If the file does not exist or cannot be read, it returns a default configuration. +func loadConfig(cwd string) Config { + configPath := filepath.Join(cwd, configFileName) + file, err := os.ReadFile(configPath) + if err != nil { + return Config{ + CustomColors: CustomColors{ + BgColor: "#0d1117", + CardBg: "#161b22", + BorderColor: "#30363d", + AccentColor: "#58a6ff", + TextPrimary: "#c9d1d9", + TextSecondary: "#8b949e", + TextMuted: "#484f58", + ButtonText: "#ffffff", + }, + } + } + + var config Config + json.Unmarshal(file, &config) + if config.CustomColors.BgColor == "" { + config.CustomColors = CustomColors{ + BgColor: "#0d1117", + CardBg: "#161b22", + BorderColor: "#30363d", + AccentColor: "#58a6ff", + TextPrimary: "#c9d1d9", + TextSecondary: "#8b949e", + TextMuted: "#484f58", + ButtonText: "#ffffff", + } + } + return config +} + +// saveConfig writes the provided configuration to the specified file path. +// It marshals the config struct to JSON with indentation. +func saveConfig(path string, config Config) { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + fmt.Println("Error saving config:", err) + return + } + os.WriteFile(path, data, 0644) +} + +// humanize converts a byte count into a human-readable string (e.g., "1.2 MiB"). +func humanize(bytes int64) string { + if bytes < 1024 { + return fmt.Sprintf("%d B", bytes) + } + exp := int(math.Log(float64(bytes)) / math.Log(1024)) + pre := "KMGTPE"[exp-1 : exp] + return fmt.Sprintf("%.1f %siB", float64(bytes)/math.Pow(1024, float64(exp)), pre) +} diff --git a/git_utils.go b/git_utils.go new file mode 100644 index 0000000..c8e3022 --- /dev/null +++ b/git_utils.go @@ -0,0 +1,685 @@ +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 +}