Borg/cmd/changelog.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

157 lines
3.8 KiB
Go

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())
}