Borg/pkg/changelog/changelog_test.go
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

159 lines
4.4 KiB
Go

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
}