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>
This commit is contained in:
google-labs-jules[bot] 2026-02-02 00:56:00 +00:00
parent cf2af53ed3
commit ac4ad6ec3e
8 changed files with 657 additions and 8 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())
}

6
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,11 +50,13 @@ 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
@ -65,4 +68,5 @@ require (
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
)

2
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=

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 {