Compare commits
1 commit
main
...
feat/archi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac4ad6ec3e |
10 changed files with 660 additions and 344 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())
|
||||
}
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Snider/Borg/pkg/compress"
|
||||
"github.com/Snider/Borg/pkg/datanode"
|
||||
"github.com/Snider/Borg/pkg/tim"
|
||||
"github.com/Snider/Borg/pkg/trix"
|
||||
"github.com/Snider/Borg/pkg/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type CollectLocalCmd struct {
|
||||
cobra.Command
|
||||
}
|
||||
|
||||
// NewCollectLocalCmd creates a new collect local command
|
||||
func NewCollectLocalCmd() *CollectLocalCmd {
|
||||
c := &CollectLocalCmd{}
|
||||
c.Command = cobra.Command{
|
||||
Use: "local [directory]",
|
||||
Short: "Collect files from a local directory",
|
||||
Long: `Collect files from a local directory and store them in a DataNode.
|
||||
|
||||
If no directory is specified, the current working directory is used.
|
||||
|
||||
Examples:
|
||||
borg collect local
|
||||
borg collect local ./src
|
||||
borg collect local /path/to/project --output project.tar
|
||||
borg collect local . --format stim --password secret
|
||||
borg collect local . --exclude "*.log" --exclude "node_modules"`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
directory := "."
|
||||
if len(args) > 0 {
|
||||
directory = args[0]
|
||||
}
|
||||
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
compression, _ := cmd.Flags().GetString("compression")
|
||||
password, _ := cmd.Flags().GetString("password")
|
||||
excludes, _ := cmd.Flags().GetStringSlice("exclude")
|
||||
includeHidden, _ := cmd.Flags().GetBool("hidden")
|
||||
respectGitignore, _ := cmd.Flags().GetBool("gitignore")
|
||||
|
||||
finalPath, err := CollectLocal(directory, outputFile, format, compression, password, excludes, includeHidden, respectGitignore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Files saved to", finalPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().String("output", "", "Output file for the DataNode")
|
||||
c.Flags().String("format", "datanode", "Output format (datanode, tim, trix, or stim)")
|
||||
c.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
|
||||
c.Flags().String("password", "", "Password for encryption (required for stim/trix format)")
|
||||
c.Flags().StringSlice("exclude", nil, "Patterns to exclude (can be specified multiple times)")
|
||||
c.Flags().Bool("hidden", false, "Include hidden files and directories")
|
||||
c.Flags().Bool("gitignore", true, "Respect .gitignore files (default: true)")
|
||||
return c
|
||||
}
|
||||
|
||||
func init() {
|
||||
collectCmd.AddCommand(&NewCollectLocalCmd().Command)
|
||||
}
|
||||
|
||||
// CollectLocal collects files from a local directory into a DataNode
|
||||
func CollectLocal(directory string, outputFile string, format string, compression string, password string, excludes []string, includeHidden bool, respectGitignore bool) (string, error) {
|
||||
// Validate format
|
||||
if format != "datanode" && format != "tim" && format != "trix" && format != "stim" {
|
||||
return "", fmt.Errorf("invalid format: %s (must be 'datanode', 'tim', 'trix', or 'stim')", format)
|
||||
}
|
||||
if (format == "stim" || format == "trix") && password == "" {
|
||||
return "", fmt.Errorf("password is required for %s format", format)
|
||||
}
|
||||
if compression != "none" && compression != "gz" && compression != "xz" {
|
||||
return "", fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
|
||||
}
|
||||
|
||||
// Resolve directory path
|
||||
absDir, err := filepath.Abs(directory)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error resolving directory path: %w", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(absDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error accessing directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("not a directory: %s", absDir)
|
||||
}
|
||||
|
||||
// Load gitignore patterns if enabled
|
||||
var gitignorePatterns []string
|
||||
if respectGitignore {
|
||||
gitignorePatterns = loadGitignore(absDir)
|
||||
}
|
||||
|
||||
// Create DataNode and collect files
|
||||
dn := datanode.New()
|
||||
var fileCount int
|
||||
|
||||
bar := ui.NewProgressBar(-1, "Scanning files")
|
||||
defer bar.Finish()
|
||||
|
||||
err = filepath.WalkDir(absDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get relative path
|
||||
relPath, err := filepath.Rel(absDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip root
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip hidden files/dirs unless explicitly included
|
||||
if !includeHidden && isHidden(relPath) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check gitignore patterns
|
||||
if respectGitignore && matchesGitignore(relPath, d.IsDir(), gitignorePatterns) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check exclude patterns
|
||||
if matchesExclude(relPath, excludes) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip directories (they're implicit in DataNode)
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read file content
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading %s: %w", relPath, err)
|
||||
}
|
||||
|
||||
// Add to DataNode with forward slashes (tar convention)
|
||||
dn.AddData(filepath.ToSlash(relPath), content)
|
||||
fileCount++
|
||||
bar.Describe(fmt.Sprintf("Collected %d files", fileCount))
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error walking directory: %w", err)
|
||||
}
|
||||
|
||||
if fileCount == 0 {
|
||||
return "", fmt.Errorf("no files found in %s", directory)
|
||||
}
|
||||
|
||||
bar.Describe(fmt.Sprintf("Packaging %d files", fileCount))
|
||||
|
||||
// Convert to output format
|
||||
var data []byte
|
||||
if format == "tim" {
|
||||
t, err := tim.FromDataNode(dn)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating tim: %w", err)
|
||||
}
|
||||
data, err = t.ToTar()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error serializing tim: %w", err)
|
||||
}
|
||||
} else if format == "stim" {
|
||||
t, err := tim.FromDataNode(dn)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating tim: %w", err)
|
||||
}
|
||||
data, err = t.ToSigil(password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error encrypting stim: %w", err)
|
||||
}
|
||||
} else if format == "trix" {
|
||||
data, err = trix.ToTrix(dn, password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error serializing trix: %w", err)
|
||||
}
|
||||
} else {
|
||||
data, err = dn.ToTar()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error serializing DataNode: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply compression
|
||||
compressedData, err := compress.Compress(data, compression)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error compressing data: %w", err)
|
||||
}
|
||||
|
||||
// Determine output filename
|
||||
if outputFile == "" {
|
||||
baseName := filepath.Base(absDir)
|
||||
if baseName == "." || baseName == "/" {
|
||||
baseName = "local"
|
||||
}
|
||||
outputFile = baseName + "." + format
|
||||
if compression != "none" {
|
||||
outputFile += "." + compression
|
||||
}
|
||||
}
|
||||
|
||||
err = os.WriteFile(outputFile, compressedData, 0644)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error writing output file: %w", err)
|
||||
}
|
||||
|
||||
return outputFile, nil
|
||||
}
|
||||
|
||||
// isHidden checks if a path component starts with a dot
|
||||
func isHidden(path string) bool {
|
||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, ".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// loadGitignore loads patterns from .gitignore if it exists
|
||||
func loadGitignore(dir string) []string {
|
||||
var patterns []string
|
||||
|
||||
gitignorePath := filepath.Join(dir, ".gitignore")
|
||||
content, err := os.ReadFile(gitignorePath)
|
||||
if err != nil {
|
||||
return patterns
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
// Skip empty lines and comments
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
patterns = append(patterns, line)
|
||||
}
|
||||
|
||||
return patterns
|
||||
}
|
||||
|
||||
// matchesGitignore checks if a path matches any gitignore pattern
|
||||
func matchesGitignore(path string, isDir bool, patterns []string) bool {
|
||||
for _, pattern := range patterns {
|
||||
// Handle directory-only patterns
|
||||
if strings.HasSuffix(pattern, "/") {
|
||||
if !isDir {
|
||||
continue
|
||||
}
|
||||
pattern = strings.TrimSuffix(pattern, "/")
|
||||
}
|
||||
|
||||
// Handle negation (simplified - just skip negated patterns)
|
||||
if strings.HasPrefix(pattern, "!") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Match against path components
|
||||
matched, _ := filepath.Match(pattern, filepath.Base(path))
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also try matching the full path
|
||||
matched, _ = filepath.Match(pattern, path)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle ** patterns (simplified)
|
||||
if strings.Contains(pattern, "**") {
|
||||
simplePattern := strings.ReplaceAll(pattern, "**", "*")
|
||||
matched, _ = filepath.Match(simplePattern, path)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesExclude checks if a path matches any exclude pattern
|
||||
func matchesExclude(path string, excludes []string) bool {
|
||||
for _, pattern := range excludes {
|
||||
// Match against basename
|
||||
matched, _ := filepath.Match(pattern, filepath.Base(path))
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
|
||||
// Match against full path
|
||||
matched, _ = filepath.Match(pattern, path)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
BIN
examples/demo-sample.smsg
Normal file
BIN
examples/demo-sample.smsg
Normal file
Binary file not shown.
8
go.mod
8
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,20 +50,23 @@ 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
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/crypto v0.44.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
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
|
||||
)
|
||||
|
|
|
|||
6
go.sum
6
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=
|
||||
|
|
@ -155,8 +157,8 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
|
|||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
|
|
|
|||
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