Merge ac4ad6ec3e into a77024aad4
This commit is contained in:
commit
dcc9125baa
8 changed files with 657 additions and 8 deletions
157
cmd/changelog.go
Normal file
157
cmd/changelog.go
Normal 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
6
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
|
||||
)
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
242
pkg/changelog/changelog.go
Normal file
242
pkg/changelog/changelog.go
Normal 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
|
||||
}
|
||||
159
pkg/changelog/changelog_test.go
Normal file
159
pkg/changelog/changelog_test.go
Normal 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
|
||||
}
|
||||
86
pkg/changelog/formatters.go
Normal file
86
pkg/changelog/formatters.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue