diff --git a/cmd/changelog.go b/cmd/changelog.go new file mode 100644 index 0000000..7435c26 --- /dev/null +++ b/cmd/changelog.go @@ -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()) +} diff --git a/go.mod b/go.mod index d1c5f08..33838bb 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 2a41157..cf3d5a8 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/changelog/changelog.go b/pkg/changelog/changelog.go new file mode 100644 index 0000000..cbbc519 --- /dev/null +++ b/pkg/changelog/changelog.go @@ -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 +} diff --git a/pkg/changelog/changelog_test.go b/pkg/changelog/changelog_test.go new file mode 100644 index 0000000..9c37bdb --- /dev/null +++ b/pkg/changelog/changelog_test.go @@ -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 +} diff --git a/pkg/changelog/formatters.go b/pkg/changelog/formatters.go new file mode 100644 index 0000000..a0dcfcc --- /dev/null +++ b/pkg/changelog/formatters.go @@ -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 +} diff --git a/pkg/datanode/datanode.go b/pkg/datanode/datanode.go index cc53da9..780d457 100644 --- a/pkg/datanode/datanode.go +++ b/pkg/datanode/datanode.go @@ -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 diff --git a/pkg/vcs/git.go b/pkg/vcs/git.go index 92e20aa..da6c55a 100644 --- a/pkg/vcs/git.go +++ b/pkg/vcs/git.go @@ -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 {