Compare commits

..

1 commit

Author SHA1 Message Date
google-labs-jules[bot]
ac4ad6ec3e feat: Add changelog command for archive comparison
This commit introduces a new `changelog` command to the `borg` CLI. This command generates a human-readable changelog between two archive versions, addressing a key user request for tracking changes over time.

Key features of the `changelog` command include:

- **File Comparison:** Detects added, modified, and removed files between two archives.
- **Diff Statistics:** Calculates line-level insertions and deletions for modified files.
- **Multiple Output Formats:** Supports plain text, Markdown, and JSON output, controlled by a `--format` flag.
- **Remote Source Comparison:** Allows comparing a local archive against a remote GitHub repository using the `--source` flag (e.g., `--source github:org/repo`).
- **Commit Message Extraction:** When comparing archives that contain a `.git` repository, the command extracts and displays the relevant commit messages for each modified file, providing valuable context for the changes.

To support this functionality, this commit also includes:

- A new `pkg/changelog` package containing the core logic for comparing archives and generating change reports.
- A bugfix in `pkg/datanode` to ensure `fs.WalkDir` functions correctly on the root of a `DataNode`, which was necessary for iterating through archive contents.
- A modification to the `pkg/vcs` Git cloner to include the `.git` directory in the created `DataNode`, enabling commit history analysis.

Co-authored-by: Snider <631881+Snider@users.noreply.github.com>
2026-02-02 00:56:00 +00:00
10 changed files with 660 additions and 344 deletions

157
cmd/changelog.go Normal file
View file

@ -0,0 +1,157 @@
package cmd
import (
"errors"
"fmt"
"os"
"strings"
"github.com/Snider/Borg/pkg/changelog"
"github.com/Snider/Borg/pkg/datanode"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
"github.com/Snider/Borg/pkg/vcs"
"github.com/spf13/cobra"
)
var changelogCmd = NewChangelogCmd()
func NewChangelogCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "changelog [old] [new]",
Short: "Generate a changelog between two archives",
Args: func(cmd *cobra.Command, args []string) error {
source, _ := cmd.Flags().GetString("source")
if source != "" {
if len(args) != 1 {
return errors.New("accepts one archive when --source is set")
}
} else {
if len(args) != 2 {
return errors.New("accepts two archives when --source is not set")
}
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
format, _ := cmd.Flags().GetString("format")
password, _ := cmd.Flags().GetString("password")
source, _ := cmd.Flags().GetString("source")
var oldNode, newNode *datanode.DataNode
var err error
if source != "" {
oldNode, err = getDataNode(args[0], password)
if err != nil {
return fmt.Errorf("failed to read old archive: %w", err)
}
newNode, err = getDataNodeFromSource(source)
if err != nil {
return fmt.Errorf("failed to read source: %w", err)
}
} else {
oldNode, err = getDataNode(args[0], password)
if err != nil {
return fmt.Errorf("failed to read old archive: %w", err)
}
newNode, err = getDataNode(args[1], password)
if err != nil {
return fmt.Errorf("failed to read new archive: %w", err)
}
}
report, err := changelog.GenerateReport(oldNode, newNode)
if err != nil {
return fmt.Errorf("failed to generate changelog: %w", err)
}
var output string
switch format {
case "markdown":
output, err = changelog.FormatAsMarkdown(report)
case "json":
output, err = changelog.FormatAsJSON(report)
default:
output, err = changelog.FormatAsText(report)
}
if err != nil {
return fmt.Errorf("failed to format changelog: %w", err)
}
fmt.Fprint(os.Stdout, output)
return nil
},
}
cmd.Flags().String("format", "text", "Output format (text, markdown, json)")
cmd.Flags().String("password", "", "Password for encrypted archives")
cmd.Flags().String("source", "", "Remote source to compare against (e.g., github:org/repo)")
return cmd
}
func getDataNode(filePath, password string) (*datanode.DataNode, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
// Handle .stim files
if strings.HasSuffix(filePath, ".stim") || (len(data) >= 4 && string(data[:4]) == "STIM") {
if password == "" {
return nil, fmt.Errorf("password required for .stim files")
}
m, err := tim.FromSigil(data, password)
if err != nil {
return nil, err
}
tarball, err := m.ToTar()
if err != nil {
return nil, err
}
return datanode.FromTar(tarball)
}
// Handle .trix files
if strings.HasSuffix(filePath, ".trix") {
dn, err := trix.FromTrix(data, password)
if err != nil {
return nil, err
}
return dn, nil
}
// Assume it's a tarball
return datanode.FromTar(data)
}
func getDataNodeFromSource(source string) (*datanode.DataNode, error) {
parts := strings.SplitN(source, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid source format: %s", source)
}
sourceType := parts[0]
sourcePath := parts[1]
switch sourceType {
case "github":
url := "https://" + sourceType + ".com/" + sourcePath
gitCloner := vcs.NewGitCloner()
return gitCloner.CloneGitRepository(url, os.Stdout)
default:
return nil, fmt.Errorf("unsupported source type: %s", sourceType)
}
}
func GetChangelogCmd() *cobra.Command {
return changelogCmd
}
func init() {
RootCmd.AddCommand(GetChangelogCmd())
}

View file

@ -1,333 +0,0 @@
package cmd
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/Snider/Borg/pkg/compress"
"github.com/Snider/Borg/pkg/datanode"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
"github.com/Snider/Borg/pkg/ui"
"github.com/spf13/cobra"
)
type CollectLocalCmd struct {
cobra.Command
}
// NewCollectLocalCmd creates a new collect local command
func NewCollectLocalCmd() *CollectLocalCmd {
c := &CollectLocalCmd{}
c.Command = cobra.Command{
Use: "local [directory]",
Short: "Collect files from a local directory",
Long: `Collect files from a local directory and store them in a DataNode.
If no directory is specified, the current working directory is used.
Examples:
borg collect local
borg collect local ./src
borg collect local /path/to/project --output project.tar
borg collect local . --format stim --password secret
borg collect local . --exclude "*.log" --exclude "node_modules"`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
directory := "."
if len(args) > 0 {
directory = args[0]
}
outputFile, _ := cmd.Flags().GetString("output")
format, _ := cmd.Flags().GetString("format")
compression, _ := cmd.Flags().GetString("compression")
password, _ := cmd.Flags().GetString("password")
excludes, _ := cmd.Flags().GetStringSlice("exclude")
includeHidden, _ := cmd.Flags().GetBool("hidden")
respectGitignore, _ := cmd.Flags().GetBool("gitignore")
finalPath, err := CollectLocal(directory, outputFile, format, compression, password, excludes, includeHidden, respectGitignore)
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), "Files saved to", finalPath)
return nil
},
}
c.Flags().String("output", "", "Output file for the DataNode")
c.Flags().String("format", "datanode", "Output format (datanode, tim, trix, or stim)")
c.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
c.Flags().String("password", "", "Password for encryption (required for stim/trix format)")
c.Flags().StringSlice("exclude", nil, "Patterns to exclude (can be specified multiple times)")
c.Flags().Bool("hidden", false, "Include hidden files and directories")
c.Flags().Bool("gitignore", true, "Respect .gitignore files (default: true)")
return c
}
func init() {
collectCmd.AddCommand(&NewCollectLocalCmd().Command)
}
// CollectLocal collects files from a local directory into a DataNode
func CollectLocal(directory string, outputFile string, format string, compression string, password string, excludes []string, includeHidden bool, respectGitignore bool) (string, error) {
// Validate format
if format != "datanode" && format != "tim" && format != "trix" && format != "stim" {
return "", fmt.Errorf("invalid format: %s (must be 'datanode', 'tim', 'trix', or 'stim')", format)
}
if (format == "stim" || format == "trix") && password == "" {
return "", fmt.Errorf("password is required for %s format", format)
}
if compression != "none" && compression != "gz" && compression != "xz" {
return "", fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
}
// Resolve directory path
absDir, err := filepath.Abs(directory)
if err != nil {
return "", fmt.Errorf("error resolving directory path: %w", err)
}
info, err := os.Stat(absDir)
if err != nil {
return "", fmt.Errorf("error accessing directory: %w", err)
}
if !info.IsDir() {
return "", fmt.Errorf("not a directory: %s", absDir)
}
// Load gitignore patterns if enabled
var gitignorePatterns []string
if respectGitignore {
gitignorePatterns = loadGitignore(absDir)
}
// Create DataNode and collect files
dn := datanode.New()
var fileCount int
bar := ui.NewProgressBar(-1, "Scanning files")
defer bar.Finish()
err = filepath.WalkDir(absDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Get relative path
relPath, err := filepath.Rel(absDir, path)
if err != nil {
return err
}
// Skip root
if relPath == "." {
return nil
}
// Skip hidden files/dirs unless explicitly included
if !includeHidden && isHidden(relPath) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Check gitignore patterns
if respectGitignore && matchesGitignore(relPath, d.IsDir(), gitignorePatterns) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Check exclude patterns
if matchesExclude(relPath, excludes) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Skip directories (they're implicit in DataNode)
if d.IsDir() {
return nil
}
// Read file content
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("error reading %s: %w", relPath, err)
}
// Add to DataNode with forward slashes (tar convention)
dn.AddData(filepath.ToSlash(relPath), content)
fileCount++
bar.Describe(fmt.Sprintf("Collected %d files", fileCount))
return nil
})
if err != nil {
return "", fmt.Errorf("error walking directory: %w", err)
}
if fileCount == 0 {
return "", fmt.Errorf("no files found in %s", directory)
}
bar.Describe(fmt.Sprintf("Packaging %d files", fileCount))
// Convert to output format
var data []byte
if format == "tim" {
t, err := tim.FromDataNode(dn)
if err != nil {
return "", fmt.Errorf("error creating tim: %w", err)
}
data, err = t.ToTar()
if err != nil {
return "", fmt.Errorf("error serializing tim: %w", err)
}
} else if format == "stim" {
t, err := tim.FromDataNode(dn)
if err != nil {
return "", fmt.Errorf("error creating tim: %w", err)
}
data, err = t.ToSigil(password)
if err != nil {
return "", fmt.Errorf("error encrypting stim: %w", err)
}
} else if format == "trix" {
data, err = trix.ToTrix(dn, password)
if err != nil {
return "", fmt.Errorf("error serializing trix: %w", err)
}
} else {
data, err = dn.ToTar()
if err != nil {
return "", fmt.Errorf("error serializing DataNode: %w", err)
}
}
// Apply compression
compressedData, err := compress.Compress(data, compression)
if err != nil {
return "", fmt.Errorf("error compressing data: %w", err)
}
// Determine output filename
if outputFile == "" {
baseName := filepath.Base(absDir)
if baseName == "." || baseName == "/" {
baseName = "local"
}
outputFile = baseName + "." + format
if compression != "none" {
outputFile += "." + compression
}
}
err = os.WriteFile(outputFile, compressedData, 0644)
if err != nil {
return "", fmt.Errorf("error writing output file: %w", err)
}
return outputFile, nil
}
// isHidden checks if a path component starts with a dot
func isHidden(path string) bool {
parts := strings.Split(filepath.ToSlash(path), "/")
for _, part := range parts {
if strings.HasPrefix(part, ".") {
return true
}
}
return false
}
// loadGitignore loads patterns from .gitignore if it exists
func loadGitignore(dir string) []string {
var patterns []string
gitignorePath := filepath.Join(dir, ".gitignore")
content, err := os.ReadFile(gitignorePath)
if err != nil {
return patterns
}
lines := strings.Split(string(content), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
patterns = append(patterns, line)
}
return patterns
}
// matchesGitignore checks if a path matches any gitignore pattern
func matchesGitignore(path string, isDir bool, patterns []string) bool {
for _, pattern := range patterns {
// Handle directory-only patterns
if strings.HasSuffix(pattern, "/") {
if !isDir {
continue
}
pattern = strings.TrimSuffix(pattern, "/")
}
// Handle negation (simplified - just skip negated patterns)
if strings.HasPrefix(pattern, "!") {
continue
}
// Match against path components
matched, _ := filepath.Match(pattern, filepath.Base(path))
if matched {
return true
}
// Also try matching the full path
matched, _ = filepath.Match(pattern, path)
if matched {
return true
}
// Handle ** patterns (simplified)
if strings.Contains(pattern, "**") {
simplePattern := strings.ReplaceAll(pattern, "**", "*")
matched, _ = filepath.Match(simplePattern, path)
if matched {
return true
}
}
}
return false
}
// matchesExclude checks if a path matches any exclude pattern
func matchesExclude(path string, excludes []string) bool {
for _, pattern := range excludes {
// Match against basename
matched, _ := filepath.Match(pattern, filepath.Base(path))
if matched {
return true
}
// Match against full path
matched, _ = filepath.Match(pattern, path)
if matched {
return true
}
}
return false
}

BIN
examples/demo-sample.smsg Normal file

Binary file not shown.

8
go.mod
View file

@ -5,7 +5,7 @@ go 1.25.0
require (
github.com/Snider/Enchantrix v0.0.2
github.com/fatih/color v1.18.0
github.com/go-git/go-git/v5 v5.16.3
github.com/go-git/go-git/v5 v5.16.4
github.com/google/go-github/v39 v39.2.0
github.com/klauspost/compress v1.18.2
github.com/mattn/go-isatty v0.0.20
@ -25,6 +25,7 @@ require (
github.com/bep/debounce v1.2.1 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
@ -49,20 +50,23 @@ require (
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/crypto v0.44.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

6
go.sum
View file

@ -39,6 +39,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8=
github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
@ -155,8 +157,8 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=

242
pkg/changelog/changelog.go Normal file
View file

@ -0,0 +1,242 @@
package changelog
import (
"bytes"
"io"
"io/fs"
"os"
"strings"
"github.com/Snider/Borg/pkg/datanode"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/storage/filesystem"
"github.com/pmezard/go-difflib/difflib"
)
// ChangeReport represents the differences between two archives.
type ChangeReport struct {
Added []string `json:"added"`
Modified []ModifiedFile `json:"modified"`
Removed []string `json:"removed"`
}
// ModifiedFile represents a file that has been modified.
type ModifiedFile struct {
Path string `json:"path"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
Commits []string `json:"commits,omitempty"`
}
// GenerateReport compares two DataNodes and returns a ChangeReport.
func GenerateReport(oldNode, newNode *datanode.DataNode) (*ChangeReport, error) {
report := &ChangeReport{}
walkOptions := datanode.WalkOptions{
SkipErrors: true,
Filter: func(path string, d fs.DirEntry) bool {
return !strings.HasPrefix(path, ".git")
},
}
oldFiles := make(map[string][]byte)
if err := oldNode.Walk(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || path == "." {
return nil
}
file, err := oldNode.Open(path)
if err != nil {
return err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return err
}
oldFiles[path] = content
return nil
}, walkOptions); err != nil {
return nil, err
}
newFiles := make(map[string][]byte)
if err := newNode.Walk(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || path == "." {
return nil
}
file, err := newNode.Open(path)
if err != nil {
return err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return err
}
newFiles[path] = content
return nil
}, walkOptions); err != nil {
return nil, err
}
commitMap, err := getCommitMap(newNode)
if err != nil {
// Not a git repo, so we can ignore this error
}
for path, newContent := range newFiles {
oldContent, ok := oldFiles[path]
if !ok {
report.Added = append(report.Added, path)
continue
}
if !bytes.Equal(oldContent, newContent) {
diff := difflib.UnifiedDiff{
A: difflib.SplitLines(string(oldContent)),
B: difflib.SplitLines(string(newContent)),
FromFile: "old/" + path,
ToFile: "new/" + path,
Context: 0,
}
diffString, err := difflib.GetUnifiedDiffString(diff)
if err != nil {
return nil, err
}
var additions, deletions int
for _, line := range strings.Split(diffString, "\n") {
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
additions++
}
if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
deletions++
}
}
report.Modified = append(report.Modified, ModifiedFile{
Path: path,
Additions: additions,
Deletions: deletions,
Commits: commitMap[path],
})
}
}
for path := range oldFiles {
if _, ok := newFiles[path]; !ok {
report.Removed = append(report.Removed, path)
}
}
return report, nil
}
func getCommitMap(node *datanode.DataNode) (map[string][]string, error) {
exists, err := node.Exists(".git", datanode.ExistsOptions{WantType: fs.ModeDir})
if err != nil || !exists {
return nil, os.ErrNotExist
}
memFS := memfs.New()
err = node.Walk(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if path == "." {
return nil
}
if d.IsDir() {
return memFS.MkdirAll(path, 0755)
}
file, err := node.Open(path)
if err != nil {
return err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return err
}
f, err := memFS.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(content)
return err
})
if err != nil {
return nil, err
}
dotGitFS, err := memFS.Chroot(".git")
if err != nil {
return nil, err
}
storer := filesystem.NewStorage(dotGitFS, nil)
repo, err := git.Open(storer, nil)
if err != nil {
return nil, err
}
commitIter, err := repo.Log(&git.LogOptions{All: true})
if err != nil {
return nil, err
}
commitMap := make(map[string][]string)
err = commitIter.ForEach(func(c *object.Commit) error {
if c.NumParents() == 0 {
tree, err := c.Tree()
if err != nil {
return err
}
walker := object.NewTreeWalker(tree, true, nil)
defer walker.Close()
for {
name, _, err := walker.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
commitMap[name] = append(commitMap[name], c.Message)
}
} else {
parent, err := c.Parent(0)
if err != nil {
return err
}
patch, err := parent.Patch(c)
if err != nil {
return err
}
for _, filePatch := range patch.FilePatches() {
from, to := filePatch.Files()
paths := make(map[string]struct{})
if from != nil {
paths[from.Path()] = struct{}{}
}
if to != nil {
paths[to.Path()] = struct{}{}
}
for p := range paths {
commitMap[p] = append(commitMap[p], c.Message)
}
}
}
return nil
})
return commitMap, err
}

View file

@ -0,0 +1,159 @@
package changelog
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/Snider/Borg/pkg/datanode"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateReport(t *testing.T) {
t.Run("NoChanges", func(t *testing.T) {
oldNode := datanode.New()
oldNode.AddData("file1.txt", []byte("hello"))
newNode := datanode.New()
newNode.AddData("file1.txt", []byte("hello"))
report, err := GenerateReport(oldNode, newNode)
assert.NoError(t, err)
assert.Empty(t, report.Added)
assert.Empty(t, report.Modified)
assert.Empty(t, report.Removed)
})
t.Run("AddedFiles", func(t *testing.T) {
oldNode := datanode.New()
newNode := datanode.New()
newNode.AddData("file1.txt", []byte("hello"))
report, err := GenerateReport(oldNode, newNode)
assert.NoError(t, err)
assert.Equal(t, []string{"file1.txt"}, report.Added)
assert.Empty(t, report.Modified)
assert.Empty(t, report.Removed)
})
t.Run("RemovedFiles", func(t *testing.T) {
oldNode := datanode.New()
oldNode.AddData("file1.txt", []byte("hello"))
newNode := datanode.New()
report, err := GenerateReport(oldNode, newNode)
assert.NoError(t, err)
assert.Empty(t, report.Added)
assert.Empty(t, report.Modified)
assert.Equal(t, []string{"file1.txt"}, report.Removed)
})
t.Run("ModifiedFiles", func(t *testing.T) {
oldNode := datanode.New()
oldNode.AddData("file1.txt", []byte("hello\nworld"))
newNode := datanode.New()
newNode.AddData("file1.txt", []byte("hello\nuniverse"))
report, err := GenerateReport(oldNode, newNode)
assert.NoError(t, err)
assert.Empty(t, report.Added)
assert.Len(t, report.Modified, 1)
assert.Equal(t, "file1.txt", report.Modified[0].Path)
assert.Equal(t, 1, report.Modified[0].Additions)
assert.Equal(t, 1, report.Modified[0].Deletions)
assert.Empty(t, report.Removed)
})
t.Run("MixedChanges", func(t *testing.T) {
oldNode := datanode.New()
oldNode.AddData("file1.txt", []byte("hello"))
oldNode.AddData("file2.txt", []byte("world"))
oldNode.AddData("file3.txt", []byte("remove me"))
newNode := datanode.New()
newNode.AddData("file1.txt", []byte("hello there"))
newNode.AddData("file2.txt", []byte("world"))
newNode.AddData("file4.txt", []byte("add me"))
report, err := GenerateReport(oldNode, newNode)
assert.NoError(t, err)
assert.Equal(t, []string{"file4.txt"}, report.Added)
assert.Len(t, report.Modified, 1)
assert.Equal(t, "file1.txt", report.Modified[0].Path)
assert.Equal(t, 1, report.Modified[0].Additions)
assert.Equal(t, 1, report.Modified[0].Deletions)
assert.Equal(t, []string{"file3.txt"}, report.Removed)
})
}
func TestGenerateReportWithCommits(t *testing.T) {
tmpDir := t.TempDir()
runCmd := func(args ...string) {
cmd := exec.Command("git", args...)
cmd.Dir = tmpDir
err := cmd.Run()
require.NoError(t, err, "git %s", strings.Join(args, " "))
}
runCmd("init")
runCmd("config", "user.email", "test@example.com")
runCmd("config", "user.name", "Test User")
err := os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("hello"), 0644)
require.NoError(t, err)
runCmd("add", "file1.txt")
runCmd("commit", "-m", "Initial commit for file1")
oldNode, err := createDataNodeFromDir(tmpDir)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("hello\nworld"), 0644)
require.NoError(t, err)
runCmd("add", "file1.txt")
runCmd("commit", "-m", "Update file1.txt")
newNode, err := createDataNodeFromDir(tmpDir)
require.NoError(t, err)
report, err := GenerateReport(oldNode, newNode)
require.NoError(t, err)
require.Len(t, report.Modified, 1)
modifiedFile := report.Modified[0]
assert.Equal(t, "file1.txt", modifiedFile.Path)
assert.Equal(t, 1, modifiedFile.Additions)
assert.Equal(t, 0, modifiedFile.Deletions)
require.Len(t, modifiedFile.Commits, 2)
assert.Contains(t, modifiedFile.Commits, "Update file1.txt\n")
assert.Contains(t, modifiedFile.Commits, "Initial commit for file1\n")
}
func createDataNodeFromDir(dir string) (*datanode.DataNode, error) {
node := datanode.New()
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if strings.Contains(path, ".git/index.lock") {
return nil
}
relPath, err := filepath.Rel(dir, path)
if err != nil {
return err
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
node.AddData(relPath, content)
return nil
})
return node, err
}

View file

@ -0,0 +1,86 @@
package changelog
import (
"encoding/json"
"fmt"
"strings"
)
// FormatAsText formats the ChangeReport as plain text.
func FormatAsText(report *ChangeReport) (string, error) {
var builder strings.Builder
if len(report.Added) > 0 {
builder.WriteString(fmt.Sprintf("Added (%d files):\n", len(report.Added)))
for _, file := range report.Added {
builder.WriteString(fmt.Sprintf("- %s\n", file))
}
builder.WriteString("\n")
}
if len(report.Modified) > 0 {
builder.WriteString(fmt.Sprintf("Modified (%d files):\n", len(report.Modified)))
for _, file := range report.Modified {
builder.WriteString(fmt.Sprintf("- %s (+%d -%d lines)\n", file.Path, file.Additions, file.Deletions))
for _, commit := range file.Commits {
builder.WriteString(fmt.Sprintf(" - %s\n", strings.Split(commit, "\n")[0]))
}
}
builder.WriteString("\n")
}
if len(report.Removed) > 0 {
builder.WriteString(fmt.Sprintf("Removed (%d files):\n", len(report.Removed)))
for _, file := range report.Removed {
builder.WriteString(fmt.Sprintf("- %s\n", file))
}
builder.WriteString("\n")
}
return builder.String(), nil
}
// FormatAsMarkdown formats the ChangeReport as Markdown.
func FormatAsMarkdown(report *ChangeReport) (string, error) {
var builder strings.Builder
builder.WriteString("# Changes\n\n")
if len(report.Added) > 0 {
builder.WriteString(fmt.Sprintf("## Added (%d files)\n", len(report.Added)))
for _, file := range report.Added {
builder.WriteString(fmt.Sprintf("- `%s`\n", file))
}
builder.WriteString("\n")
}
if len(report.Modified) > 0 {
builder.WriteString(fmt.Sprintf("## Modified (%d files)\n", len(report.Modified)))
for _, file := range report.Modified {
builder.WriteString(fmt.Sprintf("- `%s` (+%d -%d lines)\n", file.Path, file.Additions, file.Deletions))
for _, commit := range file.Commits {
builder.WriteString(fmt.Sprintf(" - *%s*\n", strings.Split(commit, "\n")[0]))
}
}
builder.WriteString("\n")
}
if len(report.Removed) > 0 {
builder.WriteString(fmt.Sprintf("## Removed (%d files)\n", len(report.Removed)))
for _, file := range report.Removed {
builder.WriteString(fmt.Sprintf("- `%s`\n", file))
}
builder.WriteString("\n")
}
return builder.String(), nil
}
// FormatAsJSON formats the ChangeReport as JSON.
func FormatAsJSON(report *ChangeReport) (string, error) {
data, err := json.MarshalIndent(report, "", " ")
if err != nil {
return "", err
}
return string(data), nil
}

View file

@ -177,11 +177,14 @@ func (d *DataNode) Stat(name string) (fs.FileInfo, error) {
if file, ok := d.files[name]; ok {
return file.Stat()
}
// Check if it's a directory
prefix := name + "/"
// The root directory always exists.
if name == "." || name == "" {
prefix = ""
return &dirInfo{name: ".", modTime: time.Now()}, nil
}
// Check if it's an implicit directory
prefix := name + "/"
for p := range d.files {
if strings.HasPrefix(p, prefix) {
return &dirInfo{name: path.Base(name), modTime: time.Now()}, nil

View file

@ -50,10 +50,6 @@ func (g *gitCloner) CloneGitRepository(repoURL string, progress io.Writer) (*dat
if err != nil {
return err
}
// Skip the .git directory
if info.IsDir() && info.Name() == ".git" {
return filepath.SkipDir
}
if !info.IsDir() {
content, err := os.ReadFile(path)
if err != nil {