From 926b4a3610416ce14c93f0aaf1ad796cdb3c831e Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sat, 6 Sep 2025 16:02:23 +0800 Subject: [PATCH 1/6] feat(dotfiles): enhance pull command with detailed per-app file status tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This enhancement adds comprehensive tracking of dotfile operations, providing: - Per-app breakdown of file operations (success/failed/skipped) - Detailed output showing individual file status with proper icons - Improved result structure for better visibility into what happened - Enhanced user experience with clearer feedback on dotfile synchronization šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- commands/dotfiles_pull.go | 114 +++++++++++++++++++++++++++++--------- 1 file changed, 87 insertions(+), 27 deletions(-) diff --git a/commands/dotfiles_pull.go b/commands/dotfiles_pull.go index 7a61be2..88ab456 100644 --- a/commands/dotfiles_pull.go +++ b/commands/dotfiles_pull.go @@ -11,6 +11,13 @@ import ( "go.opentelemetry.io/otel/trace" ) +type dotfilePullFileResult struct { + path string + isSuccess bool + isSkipped bool + isFailed bool +} + func pullDotfiles(c *cli.Context) error { ctx, span := commandTracer.Start(c.Context, "dotfiles-pull", trace.WithSpanKind(trace.SpanKindClient)) defer span.End() @@ -34,12 +41,28 @@ func pullDotfiles(c *cli.Context) error { Token: config.Token, } + // Initialize app handlers based on apps parameter + var appHandlers map[model.DotfileAppName]model.DotfileApp + // Prepare filter if apps are specified var filter *model.DotfileFilter if len(apps) > 0 { filter = &model.DotfileFilter{ Apps: apps, } + // Only include specified apps + allAppsMap := model.GetAllAppsMap() + appHandlers = make(map[model.DotfileAppName]model.DotfileApp) + for _, appNameStr := range apps { + appName := model.DotfileAppName(appNameStr) + if appHandler, exists := allAppsMap[appName]; exists { + appHandlers[appName] = appHandler + } + } + } + + if len(appHandlers) == 0 { + appHandlers = model.GetAllAppsMap() } // Fetch dotfiles from server @@ -56,31 +79,11 @@ func pullDotfiles(c *cli.Context) error { return nil } - // Initialize app handlers based on apps parameter - var allApps map[model.DotfileAppName]model.DotfileApp - if len(apps) == 0 { - // If no specific apps specified, use all available apps - allApps = model.GetAllAppsMap() - } else { - // Only include specified apps - allAppsMap := model.GetAllAppsMap() - allApps = make(map[model.DotfileAppName]model.DotfileApp) - for _, appNameStr := range apps { - appName := model.DotfileAppName(appNameStr) - if appHandler, exists := allAppsMap[appName]; exists { - allApps[appName] = appHandler - } - } - } - - // Process fetched dotfiles - totalProcessed := 0 - totalSkipped := 0 - totalFailed := 0 + result := map[model.DotfileAppName][]dotfilePullFileResult{} for _, appData := range resp.Data.FetchUser.Dotfiles.Apps { appName := model.DotfileAppName(appData.App) - app, exists := allApps[appName] + app, exists := appHandlers[appName] if !exists { logrus.Warnf("Unknown app type: %s", appData.App) continue @@ -90,7 +93,6 @@ func pullDotfiles(c *cli.Context) error { // Collect files to process for this app filesToProcess := make(map[string]string) - var pathsToBackup []string for _, file := range appData.Files { if len(file.Records) == 0 { @@ -102,6 +104,8 @@ func pullDotfiles(c *cli.Context) error { var selectedRecord *model.DotfileRecord var latestRecord *model.DotfileRecord + result[appName] = make([]dotfilePullFileResult, 0) + for i := range file.Records { record := &file.Records[i] @@ -130,7 +134,6 @@ func pullDotfiles(c *cli.Context) error { // Adjust path for current user adjustedPath := AdjustPathForCurrentUser(file.Path) filesToProcess[adjustedPath] = selectedRecord.Content - pathsToBackup = append(pathsToBackup, adjustedPath) } if len(filesToProcess) == 0 { @@ -150,7 +153,10 @@ func pullDotfiles(c *cli.Context) error { for path, content := range filesToProcess { if isEqual, exists := equalityMap[path]; exists && isEqual { logrus.Debugf("Skipping %s - content is identical", path) - totalSkipped++ + result[appName] = append(result[appName], dotfilePullFileResult{ + path: path, + isSkipped: true, + }) } else { filesToUpdate[path] = content pathsToActuallyBackup = append(pathsToActuallyBackup, path) @@ -167,12 +173,38 @@ func pullDotfiles(c *cli.Context) error { logrus.Warnf("Failed to backup files for %s: %v", appData.App, err) } + results := make([]dotfilePullFileResult, 0) // Save the updated files if err := app.Save(ctx, filesToUpdate); err != nil { logrus.Errorf("Failed to save files for %s: %v", appData.App, err) - totalFailed += len(filesToUpdate) + for f := range filesToUpdate { + results = append(results, dotfilePullFileResult{ + path: f, + isSuccess: true, + }) + } } else { - totalProcessed += len(filesToUpdate) + for f := range filesToUpdate { + results = append(results, dotfilePullFileResult{ + path: f, + isFailed: true, + }) + } + } + result[appName] = append(result[appName], results...) + } + + // Calculate totals from result map + var totalProcessed, totalFailed, totalSkipped int + for _, fileResults := range result { + for _, fileResult := range fileResults { + if fileResult.isSuccess { + totalProcessed++ + } else if fileResult.isFailed { + totalFailed++ + } else if fileResult.isSkipped { + totalSkipped++ + } } } @@ -183,6 +215,18 @@ func pullDotfiles(c *cli.Context) error { logrus.Infof("All dotfiles are up to date - Skipped: %d", totalSkipped) fmt.Println("\nāœ… All dotfiles are up to date") fmt.Printf("šŸ”„ Skipped: %d files (already identical)\n", totalSkipped) + + // Show detailed breakdown by app + for appName, fileResults := range result { + if len(fileResults) > 0 { + fmt.Printf("\nšŸ“¦ %s:\n", appName) + for _, fileResult := range fileResults { + if fileResult.isSkipped { + fmt.Printf(" šŸ”„ %s (already identical)\n", fileResult.path) + } + } + } + } } else { logrus.Infof("Pull complete - Processed: %d, Skipped: %d, Failed: %d", totalProcessed, totalSkipped, totalFailed) fmt.Printf("\nāœ… Pull complete\n") @@ -193,6 +237,22 @@ func pullDotfiles(c *cli.Context) error { if totalFailed > 0 { fmt.Printf("āš ļø Failed: %d files\n", totalFailed) } + + // Show detailed breakdown by app + for appName, fileResults := range result { + if len(fileResults) > 0 { + fmt.Printf("\nšŸ“¦ %s:\n", appName) + for _, fileResult := range fileResults { + if fileResult.isSuccess { + fmt.Printf(" āœ… %s (updated)\n", fileResult.path) + } else if fileResult.isFailed { + fmt.Printf(" āš ļø %s (failed)\n", fileResult.path) + } else if fileResult.isSkipped { + fmt.Printf(" šŸ”„ %s (already identical)\n", fileResult.path) + } + } + } + } } return nil From 3a35d1db1c5a3415eb8d5077fbab8f8e2e7d6c03 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sat, 6 Sep 2025 19:33:55 +0800 Subject: [PATCH 2/6] feat(dotfiles): add dry-run mode and experimental diff merge service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --dry-run flag to dotfiles pull command to preview changes without writing files - Show diffs for files that would be modified in dry-run mode - Update all DotfileApp implementations to support dry-run in Backup and Save methods - Add experimental DiffMergeService using go-git for advanced diff operations - Fix swapped isSuccess/isFailed logic in pull command results - Update go.mod with go-git and related dependencies for diff functionality šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- commands/dotfiles.go | 5 + commands/dotfiles_pull.go | 43 ++++--- go.mod | 17 +++ go.sum | 47 ++++++++ model/diff.go | 149 ++++++++++++++++++++++++ model/diff_test.go | 184 +++++++++++++++++++++++++++++ model/dotfile_apps.go | 27 +++-- model/dotfile_apps_test.go | 230 ++++++++++++++++++------------------- 8 files changed, 566 insertions(+), 136 deletions(-) create mode 100644 model/diff.go create mode 100644 model/diff_test.go diff --git a/commands/dotfiles.go b/commands/dotfiles.go index 0e66f5d..a867022 100644 --- a/commands/dotfiles.go +++ b/commands/dotfiles.go @@ -30,6 +30,11 @@ var DotfilesCommand *cli.Command = &cli.Command{ Aliases: []string{"a"}, Usage: "specify which apps to pull (nvim, fish, git, zsh, bash, ghostty). If empty, pulls all", }, + &cli.BoolFlag{ + Name: "dry-run", + Aliases: []string{"n"}, + Usage: "show what would be changed without actually writing files", + }, }, }, }, diff --git a/commands/dotfiles_pull.go b/commands/dotfiles_pull.go index 88ab456..3a3c51e 100644 --- a/commands/dotfiles_pull.go +++ b/commands/dotfiles_pull.go @@ -24,7 +24,8 @@ func pullDotfiles(c *cli.Context) error { SetupLogger(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER)) apps := c.StringSlice("apps") - span.SetAttributes(attribute.StringSlice("apps", apps)) + dryRun := c.Bool("dry-run") + span.SetAttributes(attribute.StringSlice("apps", apps), attribute.Bool("dry-run", dryRun)) config, err := configService.ReadConfigFile(ctx) if err != nil { @@ -168,26 +169,27 @@ func pullDotfiles(c *cli.Context) error { continue } - // Backup files that will be modified - if err := app.Backup(ctx, pathsToActuallyBackup); err != nil { + results := make([]dotfilePullFileResult, 0) + + // Backup files that will be modified (handles dry-run internally) + if err := app.Backup(ctx, pathsToActuallyBackup, dryRun); err != nil { logrus.Warnf("Failed to backup files for %s: %v", appData.App, err) } - results := make([]dotfilePullFileResult, 0) - // Save the updated files - if err := app.Save(ctx, filesToUpdate); err != nil { + // Save the updated files (handles dry-run internally) + if err := app.Save(ctx, filesToUpdate, dryRun); err != nil { logrus.Errorf("Failed to save files for %s: %v", appData.App, err) for f := range filesToUpdate { results = append(results, dotfilePullFileResult{ - path: f, - isSuccess: true, + path: f, + isFailed: true, }) } } else { for f := range filesToUpdate { results = append(results, dotfilePullFileResult{ - path: f, - isFailed: true, + path: f, + isSuccess: true, }) } } @@ -213,7 +215,11 @@ func pullDotfiles(c *cli.Context) error { fmt.Println("\nšŸ“­ No dotfiles to process") } else if totalProcessed == 0 && totalFailed == 0 { logrus.Infof("All dotfiles are up to date - Skipped: %d", totalSkipped) - fmt.Println("\nāœ… All dotfiles are up to date") + if dryRun { + fmt.Println("\nāœ… [DRY RUN] All dotfiles are up to date") + } else { + fmt.Println("\nāœ… All dotfiles are up to date") + } fmt.Printf("šŸ”„ Skipped: %d files (already identical)\n", totalSkipped) // Show detailed breakdown by app @@ -229,8 +235,13 @@ func pullDotfiles(c *cli.Context) error { } } else { logrus.Infof("Pull complete - Processed: %d, Skipped: %d, Failed: %d", totalProcessed, totalSkipped, totalFailed) - fmt.Printf("\nāœ… Pull complete\n") - fmt.Printf("šŸ“„ Updated: %d files\n", totalProcessed) + if dryRun { + fmt.Printf("\nāœ… [DRY RUN] Pull complete\n") + fmt.Printf("šŸ“„ Would update: %d files\n", totalProcessed) + } else { + fmt.Printf("\nāœ… Pull complete\n") + fmt.Printf("šŸ“„ Updated: %d files\n", totalProcessed) + } if totalSkipped > 0 { fmt.Printf("šŸ”„ Skipped: %d files (already identical)\n", totalSkipped) } @@ -244,7 +255,11 @@ func pullDotfiles(c *cli.Context) error { fmt.Printf("\nšŸ“¦ %s:\n", appName) for _, fileResult := range fileResults { if fileResult.isSuccess { - fmt.Printf(" āœ… %s (updated)\n", fileResult.path) + if dryRun { + fmt.Printf(" šŸ“„ %s (would update)\n", fileResult.path) + } else { + fmt.Printf(" āœ… %s (updated)\n", fileResult.path) + } } else if fileResult.isFailed { fmt.Printf(" āš ļø %s (failed)\n", fileResult.path) } else if fileResult.isSkipped { diff --git a/go.mod b/go.mod index 492868f..db58383 100644 --- a/go.mod +++ b/go.mod @@ -25,28 +25,43 @@ require ( ) require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cloudflare/circl v1.6.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // 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/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-git/v5 v5.16.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-resty/resty/v2 v2.16.5 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/ll v0.0.9 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect @@ -62,6 +77,7 @@ require ( go.opentelemetry.io/otel/sdk/log v0.13.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect go.opentelemetry.io/proto/otlp v1.7.0 // indirect + golang.org/x/crypto v0.40.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/term v0.33.0 // indirect @@ -70,4 +86,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074 // indirect google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index b21f6b4..0550d98 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,39 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/PromptPal/go-sdk v0.4.1 h1:gyBeUu5FqTjXoNWb51DCc0NAb4Hk+CyFFyf67SR8AS0= github.com/PromptPal/go-sdk v0.4.1/go.mod h1:67S1GmSq08wVu7Wxi//3Ru9BqcqhKcqTGey3PUJrBk8= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/ThreeDotsLabs/watermill v1.4.7 h1:LiF4wMP400/psRTdHL/IcV1YIv9htHYFggbe2d6cLeI= github.com/ThreeDotsLabs/watermill v1.4.7/go.mod h1:Ks20MyglVnqjpha1qq0kjaQ+J9ay7bdnjszQ4cW9FMU= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= +github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -22,6 +41,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -33,6 +54,10 @@ github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -58,6 +83,8 @@ github.com/olekukonko/tablewriter v1.0.8 h1:f6wJzHg4QUtJdvrVPKco4QTrAylgaU0+b9br github.com/olekukonko/tablewriter v1.0.8/go.mod h1:H428M+HzoUXC6JU2Abj9IT9ooRmdq9CxuDmKMtrOCMs= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -73,11 +100,15 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -92,6 +123,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= @@ -132,21 +165,33 @@ go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 h1:mVXdvnmR3S3BQOqHECm9NGMjYiRtEvDYcqAqedTXY6s= google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:vYFwMYFbmA8vl6Z/krj/h7+U/AqpHknwJX4Uqgfyc7I= google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074 h1:qJW29YvkiJmXOYMu5Tf8lyrTp3dOS+K4z6IixtLaCf8= @@ -159,6 +204,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/model/diff.go b/model/diff.go new file mode 100644 index 0000000..09b92ce --- /dev/null +++ b/model/diff.go @@ -0,0 +1,149 @@ +package model + +import ( + "bytes" + "errors" + "fmt" + "io" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/format/packfile" + "github.com/go-git/go-git/v5/storage/memory" + "github.com/go-git/go-git/v5/utils/diff" + "github.com/sergi/go-diff/diffmatchpatch" +) + +// DiffMergeService defines the interface for diff and merge operations +type DiffMergeService interface { + ConvertToEncodedObject(content string) (plumbing.EncodedObject, error) + FindDiff(localContent, remoteContent string) (plumbing.EncodedObject, error) + ApplyDiff(obj plumbing.EncodedObject, diff plumbing.EncodedObject) ([]byte, error) +} + +// diffMergeService implements the DiffMergeService interface +type diffMergeService struct{} + +// NewDiffMergeService creates a new instance of DiffMergeService +func NewDiffMergeService() DiffMergeService { + return &diffMergeService{} +} +func (s *diffMergeService) ConvertToEncodedObject(content string) (plumbing.EncodedObject, error) { + odb := memory.NewStorage() + + // Create blob for local content + localOid := odb.NewEncodedObject() + localOid.SetType(plumbing.BlobObject) + localOid.SetSize(int64(len(content))) + writer, err := localOid.Writer() + if err != nil { + return nil, err + } + writer.Write([]byte(content)) + writer.Close() + return localOid, err + +} + +// FindDiffAndMergeWithGitObjects uses go-git's merge functionality with git objects +func (s *diffMergeService) FindDiff(localContent, remoteContent string) (plumbing.EncodedObject, error) { + localOid, err := s.ConvertToEncodedObject(localContent) + if err != nil { + return nil, err + } + remoteOid, err := s.ConvertToEncodedObject(remoteContent) + if err != nil { + return nil, err + } + + delta, err := packfile.GetDelta(localOid, remoteOid) + return delta, err +} + +func (s *diffMergeService) ApplyDiff(obj plumbing.EncodedObject, diffs plumbing.EncodedObject) ([]byte, error) { + // Read the base object content + baseReader, err := obj.Reader() + if err != nil { + return nil, err + } + defer baseReader.Close() + + baseContent, err := io.ReadAll(baseReader) + if err != nil { + return nil, err + } + + // Read the target content from the diff object + // The diff object contains the full target content after applying packfile.GetDelta + deltaReader, err := diffs.Reader() + if err != nil { + fmt.Println(err) + return nil, err + } + defer deltaReader.Close() + + deltaContent, err := io.ReadAll(deltaReader) + if err != nil { + return nil, err + } + + if len(deltaContent) == 0 { + return baseContent, nil + } + + if len(baseContent) == 0 { + return bytes.Trim(deltaContent, "\x00"), nil + } + + // First try to apply as a delta + targetContent, err := packfile.PatchDelta(baseContent, deltaContent) + if err != nil { + if errors.Is(err, packfile.ErrInvalidDelta) { + return baseContent, nil + } + // If it fails, the delta might be the actual target content + // This happens when GetDelta creates a blob for certain cases + if len(baseContent) == 0 || diffs.Type() == plumbing.BlobObject { + targetContent = deltaContent + } else { + return nil, err + } + } + + // Now use diff.Do to find differences + // diff.Do requires strings, but we'll work with bytes for the result + changes := diff.Do(string(baseContent), string(targetContent)) + + // Build result: start with base content bytes + result := make([]byte, len(baseContent)) + copy(result, baseContent) + + // Track added content as bytes + var additions [][]byte + + // Process the diff changes + for _, change := range changes { + switch change.Type { + case diffmatchpatch.DiffInsert: + // Collect additions as bytes + additions = append(additions, []byte(change.Text)) + case diffmatchpatch.DiffDelete: + // Skip deletions - we only want additions + continue + case diffmatchpatch.DiffEqual: + // Skip unchanged parts + continue + } + } + + // Append all additions to the base content + if len(additions) > 0 { + // Add newline if base doesn't end with one + if len(result) > 0 && result[len(result)-1] != '\n' { + result = append(result, '\n') + } + // Concatenate all additions using bytes.Join + result = append(result, bytes.Join(additions, nil)...) + } + + return bytes.Trim(result, "\x00"), nil +} diff --git a/model/diff_test.go b/model/diff_test.go new file mode 100644 index 0000000..44059ff --- /dev/null +++ b/model/diff_test.go @@ -0,0 +1,184 @@ +package model + +import ( + "testing" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// diffMergeTestSuite defines the test suite for DiffMergeService +type diffMergeTestSuite struct { + suite.Suite + service DiffMergeService +} + +// SetupTest runs before each test in the suite +func (suite *diffMergeTestSuite) SetupTest() { + suite.service = NewDiffMergeService() +} + +// TestNewDiffMergeService tests the service constructor +func (suite *diffMergeTestSuite) TestNewDiffMergeService() { + require.NotNil(suite.T(), suite.service, "NewDiffMergeService() should not return nil") + + // Test that it implements the interface + var _ DiffMergeService = suite.service +} + +// TestFindDiff tests the FindDiff method with various inputs +func (s *diffMergeTestSuite) TestFindDiffAndApplyChanges() { + tests := []struct { + name string + localContent string + remoteContent string + expectError bool + expectDelta bool + finalContent string + }{ + { + name: "identical content", + localContent: "hello world", + remoteContent: "hello world", + expectError: false, + expectDelta: true, + finalContent: "hello world", + }, + { + name: "different content", + localContent: "hello world", + remoteContent: "hello universe", + expectError: false, + expectDelta: true, + finalContent: "hello world\nhello universe", + }, + { + name: "empty local content", + localContent: "", + remoteContent: "\n\nhello world", + expectError: false, + expectDelta: true, + finalContent: "\r\r\n\nhello world", + }, + { + name: "empty remote content", + localContent: "hello world", + remoteContent: "", + expectError: false, + expectDelta: true, + finalContent: "hello world", + }, + { + name: "both empty", + localContent: "", + remoteContent: "", + expectError: false, + expectDelta: true, + finalContent: "", + }, + { + name: "multiline content", + localContent: "line 1\nline 2\nline 3", + remoteContent: "line 1\nmodified line 2\nline 3", + expectError: false, + expectDelta: true, + finalContent: "line 1\nline 2\nline 3\nmodified line 2\n", + }, + { + name: "large content difference", + localContent: "short", + remoteContent: "this is a much longer string with many more characters than the original", + expectError: false, + expectDelta: true, + finalContent: "short\nthis is a much longer string with many more characters than the original", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + delta, err := s.service.FindDiff(tt.localContent, tt.remoteContent) + + if tt.expectError { + s.Error(err, "FindDiff() should return an error") + } else { + s.NoError(err, "FindDiff() should not return an error") + } + + if tt.expectDelta { + s.NotNil(delta, "FindDiff() should return a delta object") + } + + if delta != nil { + // Verify the delta is a valid encoded object + s.Contains([]plumbing.ObjectType{plumbing.OFSDeltaObject, plumbing.REFDeltaObject}, + delta.Type(), "FindDiff() should return a delta object type") + + // Verify we can read the delta size + s.GreaterOrEqual(delta.Size(), int64(0), "FindDiff() delta size should be non-negative") + } + + l, err := s.service.ConvertToEncodedObject(tt.localContent) + + s.Nil(err) + finalContent, err := s.service.ApplyDiff(l, delta) + s.Nil(err) + + s.EqualValues(tt.finalContent, string(finalContent)) + }) + } +} + +// TestFindDiffWithSpecialCharacters tests FindDiff with special characters +func (suite *diffMergeTestSuite) TestFindDiffWithSpecialCharacters() { + tests := []struct { + name string + localContent string + remoteContent string + }{ + { + name: "unicode characters", + localContent: "hello šŸŒ", + remoteContent: "hello šŸŒŽ", + }, + { + name: "special characters", + localContent: "hello\t\n\r", + remoteContent: "hello\t\n\r\x00", + }, + { + name: "json content", + localContent: `{"name": "test", "value": 123}`, + remoteContent: `{"name": "test", "value": 456}`, + }, + { + name: "binary-like content", + localContent: "\x00\x01\x02\x03", + remoteContent: "\x00\x01\x02\x04", + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + delta, err := suite.service.FindDiff(tt.localContent, tt.remoteContent) + + suite.NoError(err, "FindDiff() with special characters should not fail") + suite.NotNil(delta, "FindDiff() with special characters should return a delta") + }) + } +} + +// TestFindDiffInterface tests the interface implementation +func (suite *diffMergeTestSuite) TestFindDiffInterface() { + // Test that the service properly implements the interface + var service DiffMergeService = NewDiffMergeService() + + delta, err := service.FindDiff("test", "test") + suite.NoError(err, "Interface method FindDiff() should not fail") + suite.NotNil(delta, "Interface method FindDiff() should return a delta") +} + +// TestDiffMergeTestSuite runs the test suite +func TestDiffMergeTestSuite(t *testing.T) { + suite.Run(t, new(diffMergeTestSuite)) +} diff --git a/model/dotfile_apps.go b/model/dotfile_apps.go index f4d43e6..9371740 100644 --- a/model/dotfile_apps.go +++ b/model/dotfile_apps.go @@ -71,8 +71,8 @@ type DotfileApp interface { GetConfigPaths() []string CollectDotfiles(ctx context.Context) ([]DotfileItem, error) IsEqual(ctx context.Context, files map[string]string) (map[string]bool, error) - Backup(ctx context.Context, paths []string) error - Save(ctx context.Context, files map[string]string) error + Backup(ctx context.Context, paths []string, isDryRun bool) error + Save(ctx context.Context, files map[string]string, isDryRun bool) error } // BaseApp provides common functionality for dotfile apps @@ -226,7 +226,8 @@ func (b *BaseApp) IsEqual(_ context.Context, files map[string]string) (map[strin } // Backup creates backups of files that don't match the provided content -func (b *BaseApp) Backup(ctx context.Context, paths []string) error { +func (b *BaseApp) Backup(ctx context.Context, paths []string, isDryRun bool) error { + for _, path := range paths { expandedPath, err := b.expandPath(path) if err != nil { @@ -250,10 +251,14 @@ func (b *BaseApp) Backup(ctx context.Context, paths []string) error { continue } - if err := os.WriteFile(backupPath, existingContent, 0644); err != nil { - logrus.Warnf("Failed to create backup at %s: %v", backupPath, err) + if isDryRun { + logrus.Infof("[DRY RUN] Would create backup for %s", expandedPath) } else { - logrus.Infof("Created backup at %s", backupPath) + if err := os.WriteFile(backupPath, existingContent, 0644); err != nil { + logrus.Warnf("Failed to create backup at %s: %v", backupPath, err) + } else { + logrus.Infof("Created backup at %s", backupPath) + } } } @@ -261,7 +266,7 @@ func (b *BaseApp) Backup(ctx context.Context, paths []string) error { } // Save writes new content for files, using diff to check for actual differences -func (b *BaseApp) Save(ctx context.Context, files map[string]string) error { +func (b *BaseApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error { dmp := diffmatchpatch.New() for path, newContent := range files { @@ -288,6 +293,14 @@ func (b *BaseApp) Save(ctx context.Context, files map[string]string) error { continue } + if isDryRun { + // In dry-run mode, print the diff instead of writing files + fmt.Printf("\nšŸ“„ %s:\n", expandedPath) + prettyDiffs := dmp.DiffPrettyText(diffs) + fmt.Println(prettyDiffs) + continue + } + // Create patches from the diffs and apply them to get merged content patches := dmp.PatchMake(existingContent, diffs) mergedContent, results := dmp.PatchApply(patches, existingContent) diff --git a/model/dotfile_apps_test.go b/model/dotfile_apps_test.go index f49fe41..be8d338 100644 --- a/model/dotfile_apps_test.go +++ b/model/dotfile_apps_test.go @@ -14,33 +14,33 @@ import ( func TestBaseApp_expandPath(t *testing.T) { app := &BaseApp{name: "test"} - + t.Run("expand tilde path", func(t *testing.T) { homeDir, err := os.UserHomeDir() require.NoError(t, err) - + expanded, err := app.expandPath("~/.config/test") require.NoError(t, err) expected := filepath.Join(homeDir, ".config/test") assert.Equal(t, expected, expanded) }) - + t.Run("expand absolute path", func(t *testing.T) { testPath := "/tmp/test/config" expanded, err := app.expandPath(testPath) require.NoError(t, err) - + // Should be converted to absolute path abs, err := filepath.Abs(testPath) require.NoError(t, err) assert.Equal(t, abs, expanded) }) - + t.Run("expand relative path", func(t *testing.T) { testPath := "relative/path" expanded, err := app.expandPath(testPath) require.NoError(t, err) - + // Should be converted to absolute path abs, err := filepath.Abs(testPath) require.NoError(t, err) @@ -50,17 +50,17 @@ func TestBaseApp_expandPath(t *testing.T) { func TestBaseApp_readFileContent(t *testing.T) { app := &BaseApp{name: "test"} - + // Create temporary file for testing tmpDir, err := os.MkdirTemp("", "dotfile-test-*") require.NoError(t, err) defer os.RemoveAll(tmpDir) - + testFile := filepath.Join(tmpDir, "test.conf") testContent := "# Test configuration\nkey=value\n" err = os.WriteFile(testFile, []byte(testContent), 0644) require.NoError(t, err) - + t.Run("read existing file", func(t *testing.T) { content, modTime, err := app.readFileContent(testFile) require.NoError(t, err) @@ -68,27 +68,27 @@ func TestBaseApp_readFileContent(t *testing.T) { assert.NotNil(t, modTime) assert.False(t, modTime.IsZero()) }) - + t.Run("read non-existent file", func(t *testing.T) { nonExistentFile := filepath.Join(tmpDir, "does-not-exist.conf") _, _, err := app.readFileContent(nonExistentFile) assert.Error(t, err) }) - + t.Run("read with tilde path", func(t *testing.T) { homeDir, err := os.UserHomeDir() require.NoError(t, err) - + // Create a test file in home directory homeTestDir := filepath.Join(homeDir, ".shelltime-test") err = os.MkdirAll(homeTestDir, 0755) require.NoError(t, err) defer os.RemoveAll(homeTestDir) - + homeTestFile := filepath.Join(homeTestDir, "test.conf") err = os.WriteFile(homeTestFile, []byte(testContent), 0644) require.NoError(t, err) - + // Use tilde path tildePath := "~/.shelltime-test/test.conf" content, modTime, err := app.readFileContent(tildePath) @@ -101,43 +101,43 @@ func TestBaseApp_readFileContent(t *testing.T) { func TestBaseApp_CollectFromPaths(t *testing.T) { app := &BaseApp{name: "test"} ctx := context.Background() - + // Create temporary directory structure tmpDir, err := os.MkdirTemp("", "dotfile-test-*") require.NoError(t, err) defer os.RemoveAll(tmpDir) - + // Create test files configFile := filepath.Join(tmpDir, "config.conf") configContent := "key1=value1\n" err = os.WriteFile(configFile, []byte(configContent), 0644) require.NoError(t, err) - + // Create subdirectory with files subDir := filepath.Join(tmpDir, "subdir") err = os.MkdirAll(subDir, 0755) require.NoError(t, err) - + subFile1 := filepath.Join(subDir, "file1.txt") subFile1Content := "content1\n" err = os.WriteFile(subFile1, []byte(subFile1Content), 0644) require.NoError(t, err) - + subFile2 := filepath.Join(subDir, "file2.txt") subFile2Content := "content2\n" err = os.WriteFile(subFile2, []byte(subFile2Content), 0644) require.NoError(t, err) - + // Create hidden file (should be ignored in directories) hiddenFile := filepath.Join(subDir, ".hidden") err = os.WriteFile(hiddenFile, []byte("hidden"), 0644) require.NoError(t, err) - + t.Run("collect from single file", func(t *testing.T) { dotfiles, err := app.CollectFromPaths(ctx, "testapp", []string{configFile}) require.NoError(t, err) assert.Len(t, dotfiles, 1) - + dotfile := dotfiles[0] assert.Equal(t, "testapp", dotfile.App) assert.Equal(t, configFile, dotfile.Path) @@ -146,34 +146,34 @@ func TestBaseApp_CollectFromPaths(t *testing.T) { assert.NotNil(t, dotfile.FileModifiedAt) assert.NotEmpty(t, dotfile.Hostname) }) - + t.Run("collect from directory", func(t *testing.T) { dotfiles, err := app.CollectFromPaths(ctx, "testapp", []string{subDir}) require.NoError(t, err) - + // Should find 2 files (hidden files are ignored) assert.Len(t, dotfiles, 2) - + // Sort by path for consistent comparison if strings.Contains(dotfiles[0].Path, "file2") { dotfiles[0], dotfiles[1] = dotfiles[1], dotfiles[0] } - + assert.Equal(t, "testapp", dotfiles[0].App) assert.Equal(t, subFile1, dotfiles[0].Path) assert.Equal(t, subFile1Content, dotfiles[0].Content) - + assert.Equal(t, "testapp", dotfiles[1].App) assert.Equal(t, subFile2, dotfiles[1].Path) assert.Equal(t, subFile2Content, dotfiles[1].Content) }) - + t.Run("collect from mixed paths", func(t *testing.T) { dotfiles, err := app.CollectFromPaths(ctx, "testapp", []string{configFile, subDir}) require.NoError(t, err) assert.Len(t, dotfiles, 3) // 1 file + 2 files from directory }) - + t.Run("collect from non-existent path", func(t *testing.T) { nonExistentPath := filepath.Join(tmpDir, "does-not-exist") dotfiles, err := app.CollectFromPaths(ctx, "testapp", []string{nonExistentPath}) @@ -184,38 +184,38 @@ func TestBaseApp_CollectFromPaths(t *testing.T) { func TestBaseApp_collectFromDirectory(t *testing.T) { app := &BaseApp{name: "test"} - + // Create temporary directory structure tmpDir, err := os.MkdirTemp("", "dotfile-test-*") require.NoError(t, err) defer os.RemoveAll(tmpDir) - + // Create test files and directories file1 := filepath.Join(tmpDir, "file1.txt") err = os.WriteFile(file1, []byte("content1"), 0644) require.NoError(t, err) - + file2 := filepath.Join(tmpDir, "file2.txt") err = os.WriteFile(file2, []byte("content2"), 0644) require.NoError(t, err) - + // Create hidden file hiddenFile := filepath.Join(tmpDir, ".hidden") err = os.WriteFile(hiddenFile, []byte("hidden"), 0644) require.NoError(t, err) - + // Create subdirectory with file subDir := filepath.Join(tmpDir, "subdir") err = os.MkdirAll(subDir, 0755) require.NoError(t, err) - + subFile := filepath.Join(subDir, "subfile.txt") err = os.WriteFile(subFile, []byte("subcontent"), 0644) require.NoError(t, err) - + files, err := app.collectFromDirectory(tmpDir) require.NoError(t, err) - + // Should include regular files but not hidden files assert.Contains(t, files, file1) assert.Contains(t, files, file2) @@ -227,75 +227,75 @@ func TestBaseApp_collectFromDirectory(t *testing.T) { func TestBaseApp_IsEqual(t *testing.T) { app := &BaseApp{name: "test"} ctx := context.Background() - + // Create temporary files for testing tmpDir, err := os.MkdirTemp("", "dotfile-test-*") require.NoError(t, err) defer os.RemoveAll(tmpDir) - + testFile1 := filepath.Join(tmpDir, "file1.txt") testContent1 := "content1\n" err = os.WriteFile(testFile1, []byte(testContent1), 0644) require.NoError(t, err) - + testFile2 := filepath.Join(tmpDir, "file2.txt") testContent2 := "content2\n" err = os.WriteFile(testFile2, []byte(testContent2), 0644) require.NoError(t, err) - + t.Run("files are equal", func(t *testing.T) { files := map[string]string{ testFile1: testContent1, testFile2: testContent2, } - + result, err := app.IsEqual(ctx, files) require.NoError(t, err) assert.True(t, result[testFile1]) assert.True(t, result[testFile2]) }) - + t.Run("files are not equal", func(t *testing.T) { files := map[string]string{ testFile1: testContent1, testFile2: "different content\n", } - + result, err := app.IsEqual(ctx, files) require.NoError(t, err) assert.True(t, result[testFile1]) assert.False(t, result[testFile2]) }) - + t.Run("file does not exist locally", func(t *testing.T) { nonExistentFile := filepath.Join(tmpDir, "does-not-exist.txt") files := map[string]string{ nonExistentFile: "some content", } - + result, err := app.IsEqual(ctx, files) require.NoError(t, err) assert.False(t, result[nonExistentFile]) }) - + t.Run("with tilde path", func(t *testing.T) { homeDir, err := os.UserHomeDir() require.NoError(t, err) - + // Create test file in home directory homeTestDir := filepath.Join(homeDir, ".shelltime-test") err = os.MkdirAll(homeTestDir, 0755) require.NoError(t, err) defer os.RemoveAll(homeTestDir) - + homeTestFile := filepath.Join(homeTestDir, "test.txt") err = os.WriteFile(homeTestFile, []byte(testContent1), 0644) require.NoError(t, err) - + files := map[string]string{ "~/.shelltime-test/test.txt": testContent1, } - + result, err := app.IsEqual(ctx, files) require.NoError(t, err) assert.True(t, result["~/.shelltime-test/test.txt"]) @@ -305,25 +305,25 @@ func TestBaseApp_IsEqual(t *testing.T) { func TestBaseApp_Backup(t *testing.T) { app := &BaseApp{name: "test"} ctx := context.Background() - + // Create temporary files for testing tmpDir, err := os.MkdirTemp("", "dotfile-test-*") require.NoError(t, err) defer os.RemoveAll(tmpDir) - + testFile := filepath.Join(tmpDir, "file.txt") testContent := "original content\n" err = os.WriteFile(testFile, []byte(testContent), 0644) require.NoError(t, err) - + t.Run("backup existing file", func(t *testing.T) { - err := app.Backup(ctx, []string{testFile}) + err := app.Backup(ctx, []string{testFile}, false) require.NoError(t, err) - + // Check that backup file was created files, err := os.ReadDir(tmpDir) require.NoError(t, err) - + var backupFile string for _, file := range files { if strings.HasPrefix(file.Name(), "file.txt.backup.") { @@ -331,42 +331,42 @@ func TestBaseApp_Backup(t *testing.T) { break } } - + assert.NotEmpty(t, backupFile, "Backup file should be created") - + // Check backup content backupContent, err := os.ReadFile(backupFile) require.NoError(t, err) assert.Equal(t, testContent, string(backupContent)) }) - + t.Run("backup non-existent file", func(t *testing.T) { nonExistentFile := filepath.Join(tmpDir, "does-not-exist.txt") - err := app.Backup(ctx, []string{nonExistentFile}) + err := app.Backup(ctx, []string{nonExistentFile}, false) require.NoError(t, err) // Should not error, just skip }) - + t.Run("backup with tilde path", func(t *testing.T) { homeDir, err := os.UserHomeDir() require.NoError(t, err) - + // Create test file in home directory homeTestDir := filepath.Join(homeDir, ".shelltime-test") err = os.MkdirAll(homeTestDir, 0755) require.NoError(t, err) defer os.RemoveAll(homeTestDir) - + homeTestFile := filepath.Join(homeTestDir, "test.txt") err = os.WriteFile(homeTestFile, []byte(testContent), 0644) require.NoError(t, err) - - err = app.Backup(ctx, []string{"~/.shelltime-test/test.txt"}) + + err = app.Backup(ctx, []string{"~/.shelltime-test/test.txt"}, false) require.NoError(t, err) - + // Check that backup was created files, err := os.ReadDir(homeTestDir) require.NoError(t, err) - + backupExists := false for _, file := range files { if strings.HasPrefix(file.Name(), "test.txt.backup.") { @@ -381,115 +381,115 @@ func TestBaseApp_Backup(t *testing.T) { func TestBaseApp_Save(t *testing.T) { app := &BaseApp{name: "test"} ctx := context.Background() - + // Create temporary directory for testing tmpDir, err := os.MkdirTemp("", "dotfile-test-*") require.NoError(t, err) defer os.RemoveAll(tmpDir) - + t.Run("save new file", func(t *testing.T) { testFile := filepath.Join(tmpDir, "new-file.txt") testContent := "new content\n" - + files := map[string]string{ testFile: testContent, } - - err := app.Save(ctx, files) + + err := app.Save(ctx, files, false) require.NoError(t, err) - + // Check that file was created savedContent, err := os.ReadFile(testFile) require.NoError(t, err) assert.Equal(t, testContent, string(savedContent)) }) - + t.Run("save to existing file with different content", func(t *testing.T) { testFile := filepath.Join(tmpDir, "existing-file.txt") originalContent := "original content\n" newContent := "updated content\n" - + // Create original file err := os.WriteFile(testFile, []byte(originalContent), 0644) require.NoError(t, err) - + files := map[string]string{ testFile: newContent, } - - err = app.Save(ctx, files) + + err = app.Save(ctx, files, false) require.NoError(t, err) - + // Check that file was updated savedContent, err := os.ReadFile(testFile) require.NoError(t, err) assert.Equal(t, newContent, string(savedContent)) }) - + t.Run("save identical content skips file", func(t *testing.T) { testFile := filepath.Join(tmpDir, "identical-file.txt") content := "same content\n" - + // Create original file err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) - + // Get original mod time originalInfo, err := os.Stat(testFile) require.NoError(t, err) originalModTime := originalInfo.ModTime() - + // Wait a bit to ensure mod time would change if file is written time.Sleep(10 * time.Millisecond) - + files := map[string]string{ testFile: content, } - - err = app.Save(ctx, files) + + err = app.Save(ctx, files, false) require.NoError(t, err) - + // Check that file mod time didn't change (file was not written) newInfo, err := os.Stat(testFile) require.NoError(t, err) assert.Equal(t, originalModTime, newInfo.ModTime(), "File should not be modified when content is identical") }) - + t.Run("save creates directory if needed", func(t *testing.T) { nestedFile := filepath.Join(tmpDir, "nested", "dir", "file.txt") content := "nested content\n" - + files := map[string]string{ nestedFile: content, } - - err := app.Save(ctx, files) + + err := app.Save(ctx, files, false) require.NoError(t, err) - + // Check that directories were created and file was saved savedContent, err := os.ReadFile(nestedFile) require.NoError(t, err) assert.Equal(t, content, string(savedContent)) }) - + t.Run("save with tilde path", func(t *testing.T) { homeDir, err := os.UserHomeDir() require.NoError(t, err) - + // Create test directory in home homeTestDir := filepath.Join(homeDir, ".shelltime-test") err = os.MkdirAll(homeTestDir, 0755) require.NoError(t, err) defer os.RemoveAll(homeTestDir) - + testContent := "tilde content\n" files := map[string]string{ "~/.shelltime-test/tilde-file.txt": testContent, } - - err = app.Save(ctx, files) + + err = app.Save(ctx, files, false) require.NoError(t, err) - + // Check that file was saved savedFile := filepath.Join(homeTestDir, "tilde-file.txt") savedContent, err := os.ReadFile(savedFile) @@ -501,70 +501,70 @@ func TestBaseApp_Save(t *testing.T) { func TestBaseApp_Integration(t *testing.T) { app := &BaseApp{name: "integration-test"} ctx := context.Background() - + // Create temporary directory for testing tmpDir, err := os.MkdirTemp("", "dotfile-integration-*") require.NoError(t, err) defer os.RemoveAll(tmpDir) - + // Set up test files configFile := filepath.Join(tmpDir, "config.conf") configContent := "setting1=value1\nsetting2=value2\n" err = os.WriteFile(configFile, []byte(configContent), 0644) require.NoError(t, err) - + subDir := filepath.Join(tmpDir, "configs") err = os.MkdirAll(subDir, 0755) require.NoError(t, err) - + subFile := filepath.Join(subDir, "app.conf") subContent := "app_setting=app_value\n" err = os.WriteFile(subFile, []byte(subContent), 0644) require.NoError(t, err) - + t.Run("full workflow", func(t *testing.T) { // 1. Collect dotfiles dotfiles, err := app.CollectFromPaths(ctx, "testapp", []string{configFile, subDir}) require.NoError(t, err) assert.Len(t, dotfiles, 2) - + // 2. Check equality (should be equal initially) files := make(map[string]string) for _, dotfile := range dotfiles { files[dotfile.Path] = dotfile.Content } - + equality, err := app.IsEqual(ctx, files) require.NoError(t, err) assert.True(t, equality[configFile]) assert.True(t, equality[subFile]) - + // 3. Modify content and check inequality modifiedFiles := map[string]string{ configFile: configContent + "new_setting=new_value\n", subFile: subContent, } - + equality, err = app.IsEqual(ctx, modifiedFiles) require.NoError(t, err) assert.False(t, equality[configFile]) assert.True(t, equality[subFile]) - + // 4. Backup original files - err = app.Backup(ctx, []string{configFile, subFile}) + err = app.Backup(ctx, []string{configFile, subFile}, false) require.NoError(t, err) - + // 5. Save modified content - err = app.Save(ctx, modifiedFiles) + err = app.Save(ctx, modifiedFiles, false) require.NoError(t, err) - + // 6. Verify files were updated updatedContent, err := os.ReadFile(configFile) require.NoError(t, err) assert.Equal(t, modifiedFiles[configFile], string(updatedContent)) - + unchangedContent, err := os.ReadFile(subFile) require.NoError(t, err) assert.Equal(t, subContent, string(unchangedContent)) // Should remain unchanged }) -} \ No newline at end of file +} From e3f9ecfafa4d7fd2024a10d40fac672131715d54 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sat, 6 Sep 2025 19:48:32 +0800 Subject: [PATCH 3/6] fix(model): split ApplyDiff into GetChanges and ApplyDiff methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract diff computation logic into separate GetChanges method - GetChanges returns []diffmatchpatch.Diff directly - ApplyDiff now takes baseContent string and changes array - Update tests to use the new method signatures - Improve separation of concerns between diff calculation and application šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- model/diff.go | 21 +++++++++++++++------ model/diff_test.go | 5 +++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/model/diff.go b/model/diff.go index 09b92ce..d58cbf6 100644 --- a/model/diff.go +++ b/model/diff.go @@ -17,7 +17,8 @@ import ( type DiffMergeService interface { ConvertToEncodedObject(content string) (plumbing.EncodedObject, error) FindDiff(localContent, remoteContent string) (plumbing.EncodedObject, error) - ApplyDiff(obj plumbing.EncodedObject, diff plumbing.EncodedObject) ([]byte, error) + GetChanges(obj plumbing.EncodedObject, diff plumbing.EncodedObject) ([]diffmatchpatch.Diff, error) + ApplyDiff(baseContent string, changes []diffmatchpatch.Diff) ([]byte, error) } // diffMergeService implements the DiffMergeService interface @@ -59,7 +60,8 @@ func (s *diffMergeService) FindDiff(localContent, remoteContent string) (plumbin return delta, err } -func (s *diffMergeService) ApplyDiff(obj plumbing.EncodedObject, diffs plumbing.EncodedObject) ([]byte, error) { +// GetChanges extracts the diff changes between base object and diff object +func (s *diffMergeService) GetChanges(obj plumbing.EncodedObject, diffs plumbing.EncodedObject) ([]diffmatchpatch.Diff, error) { // Read the base object content baseReader, err := obj.Reader() if err != nil { @@ -87,18 +89,21 @@ func (s *diffMergeService) ApplyDiff(obj plumbing.EncodedObject, diffs plumbing. } if len(deltaContent) == 0 { - return baseContent, nil + return []diffmatchpatch.Diff{}, nil } if len(baseContent) == 0 { - return bytes.Trim(deltaContent, "\x00"), nil + // If base is empty, treat everything as an insert + return []diffmatchpatch.Diff{ + {Type: diffmatchpatch.DiffInsert, Text: string(bytes.Trim(deltaContent, "\x00"))}, + }, nil } // First try to apply as a delta targetContent, err := packfile.PatchDelta(baseContent, deltaContent) if err != nil { if errors.Is(err, packfile.ErrInvalidDelta) { - return baseContent, nil + return []diffmatchpatch.Diff{}, nil } // If it fails, the delta might be the actual target content // This happens when GetDelta creates a blob for certain cases @@ -110,9 +115,13 @@ func (s *diffMergeService) ApplyDiff(obj plumbing.EncodedObject, diffs plumbing. } // Now use diff.Do to find differences - // diff.Do requires strings, but we'll work with bytes for the result changes := diff.Do(string(baseContent), string(targetContent)) + return changes, nil +} + +// ApplyDiff applies diff changes to produce the final merged content +func (s *diffMergeService) ApplyDiff(baseContent string, changes []diffmatchpatch.Diff) ([]byte, error) { // Build result: start with base content bytes result := make([]byte, len(baseContent)) copy(result, baseContent) diff --git a/model/diff_test.go b/model/diff_test.go index 44059ff..2756181 100644 --- a/model/diff_test.go +++ b/model/diff_test.go @@ -121,9 +121,10 @@ func (s *diffMergeTestSuite) TestFindDiffAndApplyChanges() { l, err := s.service.ConvertToEncodedObject(tt.localContent) s.Nil(err) - finalContent, err := s.service.ApplyDiff(l, delta) + changes, err := s.service.GetChanges(l, delta) + s.Nil(err) + finalContent, err := s.service.ApplyDiff(tt.localContent, changes) s.Nil(err) - s.EqualValues(tt.finalContent, string(finalContent)) }) } From 5f695313e4098d8446ae1f1eb5ed654cc532c29b Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sat, 6 Sep 2025 19:49:39 +0800 Subject: [PATCH 4/6] fix(model): update dotfile_apps to use new DiffMergeService API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace diffmatchpatch direct usage with DiffMergeService - Use GetChanges and ApplyDiff methods for file merging - Simplify merge logic using the new service interface - Remove unused diffmatchpatch import šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- model/dotfile_apps.go | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/model/dotfile_apps.go b/model/dotfile_apps.go index 9371740..0823cbe 100644 --- a/model/dotfile_apps.go +++ b/model/dotfile_apps.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/sergi/go-diff/diffmatchpatch" "github.com/sirupsen/logrus" ) @@ -267,7 +266,8 @@ func (b *BaseApp) Backup(ctx context.Context, paths []string, isDryRun bool) err // Save writes new content for files, using diff to check for actual differences func (b *BaseApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error { - dmp := diffmatchpatch.New() + + dms := NewDiffMergeService() for path, newContent := range files { expandedPath, err := b.expandPath(path) @@ -285,31 +285,33 @@ func (b *BaseApp) Save(ctx context.Context, files map[string]string, isDryRun bo continue } - // Check for differences using go-diff - diffs := dmp.DiffMain(existingContent, newContent, false) - if len(diffs) == 1 && diffs[0].Type == diffmatchpatch.DiffEqual { - // No differences found, skip saving - logrus.Debugf("Skipping %s - content is identical", path) - continue + localObj, err := dms.ConvertToEncodedObject(existingContent) + if err != nil { + return err + } + + delta, err := dms.FindDiff(existingContent, newContent) + + if err != nil { + return err + } + + changes, err := dms.GetChanges(localObj, delta) + if err != nil { + return err } if isDryRun { // In dry-run mode, print the diff instead of writing files fmt.Printf("\nšŸ“„ %s:\n", expandedPath) - prettyDiffs := dmp.DiffPrettyText(diffs) - fmt.Println(prettyDiffs) + // TODO: add pretty output for changes continue } - // Create patches from the diffs and apply them to get merged content - patches := dmp.PatchMake(existingContent, diffs) - mergedContent, results := dmp.PatchApply(patches, existingContent) + mergedContent, err := dms.ApplyDiff(existingContent, changes) - // Check if patches were applied successfully - for i, success := range results { - if !success { - logrus.Warnf("Failed to apply patch %d for %s", i, path) - } + if err != nil { + return err } // Ensure directory exists @@ -320,7 +322,7 @@ func (b *BaseApp) Save(ctx context.Context, files map[string]string, isDryRun bo } // Write merged content - if err := os.WriteFile(expandedPath, []byte(mergedContent), 0644); err != nil { + if err := os.WriteFile(expandedPath, mergedContent, 0644); err != nil { logrus.Warnf("Failed to save file %s: %v", expandedPath, err) } else { logrus.Infof("Saved new content to %s", expandedPath) From c696d5fb5ed78df3034c5d49760c77f1c808995c Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sat, 6 Sep 2025 20:19:54 +0800 Subject: [PATCH 5/6] feat(dotfiles): add pterm for beautiful pull command output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace emoji-based output with pterm tables and styled components - Add structured summary statistics with color-coded status indicators - Implement detailed file status table for updated/failed files - Enhance dry-run mode with clear visual differentiation - Add pterm dependency for terminal UI improvements - Implement PrettyPrint method in DiffMergeService for styled diff output šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- commands/dotfiles_pull.go | 177 ++++++++++++++++++++++---------------- go.mod | 6 ++ go.sum | 71 +++++++++++++++ model/diff.go | 76 ++++++++++++++++ model/dotfile_apps.go | 1 + 5 files changed, 258 insertions(+), 73 deletions(-) diff --git a/commands/dotfiles_pull.go b/commands/dotfiles_pull.go index 3a3c51e..2192b79 100644 --- a/commands/dotfiles_pull.go +++ b/commands/dotfiles_pull.go @@ -5,6 +5,7 @@ import ( "os" "github.com/malamtime/cli/model" + "github.com/pterm/pterm" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" "go.opentelemetry.io/otel/attribute" @@ -18,6 +19,107 @@ type dotfilePullFileResult struct { isFailed bool } +// printPullResults prints the pull operation results in a formatted way +func printPullResults(result map[model.DotfileAppName][]dotfilePullFileResult, dryRun bool) { + // Calculate totals from result map + var totalProcessed, totalFailed, totalSkipped int + for _, fileResults := range result { + for _, fileResult := range fileResults { + if fileResult.isSuccess { + totalProcessed++ + } else if fileResult.isFailed { + totalFailed++ + } else if fileResult.isSkipped { + totalSkipped++ + } + } + } + + // No files to process + if totalProcessed == 0 && totalFailed == 0 && totalSkipped == 0 { + logrus.Infoln("No dotfiles found to process") + pterm.Info.Println("No dotfiles to process") + return + } + + // Print header + fmt.Println() + if dryRun { + pterm.DefaultHeader.WithBackgroundStyle(pterm.NewStyle(pterm.BgBlue)).Println("DRY RUN - Dotfiles Pull Summary") + } else { + pterm.DefaultHeader.WithBackgroundStyle(pterm.NewStyle(pterm.BgGreen)).Println("Dotfiles Pull Summary") + } + + // Print summary statistics + summaryData := pterm.TableData{ + {"Status", "Count"}, + } + + if totalProcessed > 0 { + if dryRun { + summaryData = append(summaryData, []string{pterm.FgYellow.Sprint("Would Update"), fmt.Sprintf("%d", totalProcessed)}) + } else { + summaryData = append(summaryData, []string{pterm.FgGreen.Sprint("Updated"), fmt.Sprintf("%d", totalProcessed)}) + } + } + + if totalFailed > 0 { + summaryData = append(summaryData, []string{pterm.FgRed.Sprint("Failed"), fmt.Sprintf("%d", totalFailed)}) + } + + if totalSkipped > 0 { + summaryData = append(summaryData, []string{pterm.FgGray.Sprint("Skipped"), fmt.Sprintf("%d", totalSkipped)}) + } + + pterm.DefaultTable.WithHasHeader().WithData(summaryData).Render() + + // If there are no updates or failures, just show the summary + if totalProcessed == 0 && totalFailed == 0 { + pterm.Success.Println("All dotfiles are up to date") + return + } + + // Build detailed table for updated and failed files + var detailsData pterm.TableData + detailsData = append(detailsData, []string{"App", "File", "Status"}) + + // Collect all non-skipped files + for appName, fileResults := range result { + for _, fileResult := range fileResults { + if fileResult.isSkipped { + continue // Skip files that are already identical + } + + var status string + if fileResult.isSuccess { + if dryRun { + status = pterm.FgYellow.Sprint("Would Update") + } else { + status = pterm.FgGreen.Sprint("Updated") + } + } else if fileResult.isFailed { + status = pterm.FgRed.Sprint("Failed") + } + + detailsData = append(detailsData, []string{ + string(appName), + fileResult.path, + status, + }) + } + } + + // Only show details table if there are updated or failed files + if len(detailsData) > 1 { + fmt.Println() // Add spacing + pterm.DefaultSection.Println("File Details") + pterm.DefaultTable.WithHasHeader().WithData(detailsData).Render() + } + + // Log for debugging + logrus.Infof("Pull complete - Processed: %d, Skipped: %d, Failed: %d", totalProcessed, totalSkipped, totalFailed) +} + func pullDotfiles(c *cli.Context) error { ctx, span := commandTracer.Start(c.Context, "dotfiles-pull", trace.WithSpanKind(trace.SpanKindClient)) defer span.End() @@ -196,79 +298,8 @@ func pullDotfiles(c *cli.Context) error { result[appName] = append(result[appName], results...) } - // Calculate totals from result map - var totalProcessed, totalFailed, totalSkipped int - for _, fileResults := range result { - for _, fileResult := range fileResults { - if fileResult.isSuccess { - totalProcessed++ - } else if fileResult.isFailed { - totalFailed++ - } else if fileResult.isSkipped { - totalSkipped++ - } - } - } - - if totalProcessed == 0 && totalFailed == 0 && totalSkipped == 0 { - logrus.Infoln("No dotfiles found to process") - fmt.Println("\nšŸ“­ No dotfiles to process") - } else if totalProcessed == 0 && totalFailed == 0 { - logrus.Infof("All dotfiles are up to date - Skipped: %d", totalSkipped) - if dryRun { - fmt.Println("\nāœ… [DRY RUN] All dotfiles are up to date") - } else { - fmt.Println("\nāœ… All dotfiles are up to date") - } - fmt.Printf("šŸ”„ Skipped: %d files (already identical)\n", totalSkipped) - - // Show detailed breakdown by app - for appName, fileResults := range result { - if len(fileResults) > 0 { - fmt.Printf("\nšŸ“¦ %s:\n", appName) - for _, fileResult := range fileResults { - if fileResult.isSkipped { - fmt.Printf(" šŸ”„ %s (already identical)\n", fileResult.path) - } - } - } - } - } else { - logrus.Infof("Pull complete - Processed: %d, Skipped: %d, Failed: %d", totalProcessed, totalSkipped, totalFailed) - if dryRun { - fmt.Printf("\nāœ… [DRY RUN] Pull complete\n") - fmt.Printf("šŸ“„ Would update: %d files\n", totalProcessed) - } else { - fmt.Printf("\nāœ… Pull complete\n") - fmt.Printf("šŸ“„ Updated: %d files\n", totalProcessed) - } - if totalSkipped > 0 { - fmt.Printf("šŸ”„ Skipped: %d files (already identical)\n", totalSkipped) - } - if totalFailed > 0 { - fmt.Printf("āš ļø Failed: %d files\n", totalFailed) - } - - // Show detailed breakdown by app - for appName, fileResults := range result { - if len(fileResults) > 0 { - fmt.Printf("\nšŸ“¦ %s:\n", appName) - for _, fileResult := range fileResults { - if fileResult.isSuccess { - if dryRun { - fmt.Printf(" šŸ“„ %s (would update)\n", fileResult.path) - } else { - fmt.Printf(" āœ… %s (updated)\n", fileResult.path) - } - } else if fileResult.isFailed { - fmt.Printf(" āš ļø %s (failed)\n", fileResult.path) - } else if fileResult.isSkipped { - fmt.Printf(" šŸ”„ %s (already identical)\n", fileResult.path) - } - } - } - } - } + // Print the results + printPullResults(result, dryRun) return nil } diff --git a/go.mod b/go.mod index db58383..027f3af 100644 --- a/go.mod +++ b/go.mod @@ -25,11 +25,15 @@ require ( ) require ( + atomicgo.dev/cursor v0.2.0 // indirect + atomicgo.dev/keyboard v0.2.9 // indirect + atomicgo.dev/schedule v0.1.0 // indirect dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cloudflare/circl v1.6.1 // indirect + github.com/containerd/console v1.0.5 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -47,6 +51,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -55,6 +60,7 @@ require ( github.com/olekukonko/ll v0.0.9 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pterm/pterm v0.12.81 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect diff --git a/go.sum b/go.sum index 0550d98..951f97a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,18 @@ +atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= +atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= +atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= +atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= +atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= +atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= +github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= +github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= +github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= +github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= +github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= +github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= @@ -9,12 +22,16 @@ github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNx github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/ThreeDotsLabs/watermill v1.4.7 h1:LiF4wMP400/psRTdHL/IcV1YIv9htHYFggbe2d6cLeI= github.com/ThreeDotsLabs/watermill v1.4.7/go.mod h1:Ks20MyglVnqjpha1qq0kjaQ+J9ay7bdnjszQ4cW9FMU= +github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= +github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= @@ -50,6 +67,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= +github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= @@ -58,6 +77,9 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -65,12 +87,15 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= @@ -91,6 +116,15 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= +github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= +github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= +github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= +github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= +github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= +github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= +github.com/pterm/pterm v0.12.81 h1:ju+j5I2++FO1jBKMmscgh5h5DPFDFMB7epEjSoKehKA= +github.com/pterm/pterm v0.12.81/go.mod h1:TyuyrPjnxfwP+ccJdBTeWHtd/e0ybQHkOS/TakajZCw= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -98,6 +132,7 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -110,6 +145,7 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= @@ -125,10 +161,12 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= @@ -165,33 +203,64 @@ go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 h1:mVXdvnmR3S3BQOqHECm9NGMjYiRtEvDYcqAqedTXY6s= google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:vYFwMYFbmA8vl6Z/krj/h7+U/AqpHknwJX4Uqgfyc7I= google.golang.org/genproto/googleapis/rpc v0.0.0-20250721164621-a45f3dfb1074 h1:qJW29YvkiJmXOYMu5Tf8lyrTp3dOS+K4z6IixtLaCf8= @@ -207,7 +276,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/model/diff.go b/model/diff.go index d58cbf6..63260ce 100644 --- a/model/diff.go +++ b/model/diff.go @@ -5,11 +5,13 @@ import ( "errors" "fmt" "io" + "strings" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/format/packfile" "github.com/go-git/go-git/v5/storage/memory" "github.com/go-git/go-git/v5/utils/diff" + "github.com/pterm/pterm" "github.com/sergi/go-diff/diffmatchpatch" ) @@ -19,6 +21,7 @@ type DiffMergeService interface { FindDiff(localContent, remoteContent string) (plumbing.EncodedObject, error) GetChanges(obj plumbing.EncodedObject, diff plumbing.EncodedObject) ([]diffmatchpatch.Diff, error) ApplyDiff(baseContent string, changes []diffmatchpatch.Diff) ([]byte, error) + PrettyPrint(diffs []diffmatchpatch.Diff) string } // diffMergeService implements the DiffMergeService interface @@ -156,3 +159,76 @@ func (s *diffMergeService) ApplyDiff(baseContent string, changes []diffmatchpatc return bytes.Trim(result, "\x00"), nil } + +// PrettyPrint renders diff changes in a beautiful format using pterm (shows added lines only) +func (s *diffMergeService) PrettyPrint(diffs []diffmatchpatch.Diff) string { + // Filter for added lines only + var hasAdditions bool + for _, diff := range diffs { + if diff.Type == diffmatchpatch.DiffInsert { + hasAdditions = true + break + } + } + + if !hasAdditions { + return pterm.Info.Sprint("No additions detected") + } + + var builder strings.Builder + lineNum := 1 + + // Create styled renderers + addStyle := pterm.NewStyle(pterm.FgGreen, pterm.BgDefault) + lineNumStyle := pterm.NewStyle(pterm.FgCyan) + + // Header + header := pterm.DefaultBox.WithTitle("Added Lines").Sprint("") + builder.WriteString(header) + builder.WriteString("\n\n") + + // Process only insertions + for _, diff := range diffs { + if diff.Type != diffmatchpatch.DiffInsert { + continue + } + + lines := strings.Split(diff.Text, "\n") + + for i, line := range lines { + // Skip empty lines at the end of the diff text + if i == len(lines)-1 && line == "" { + continue + } + + // Format line number + lineNumStr := fmt.Sprintf("%4d │ ", lineNum) + builder.WriteString(lineNumStyle.Sprint(lineNumStr)) + + // Print the added line + builder.WriteString(addStyle.Sprint("+ " + line)) + builder.WriteString("\n") + lineNum++ + } + } + + // Summary section + var addCount int + for _, diff := range diffs { + if diff.Type == diffmatchpatch.DiffInsert { + lineCount := strings.Count(diff.Text, "\n") + if lineCount == 0 && diff.Text != "" { + lineCount = 1 + } + addCount += lineCount + } + } + + // Create summary + builder.WriteString("\n") + builder.WriteString(pterm.DefaultSection.Sprint("Summary")) + summary := pterm.Success.Sprintf("Total lines added: %d", addCount) + builder.WriteString(summary) + + return builder.String() +} diff --git a/model/dotfile_apps.go b/model/dotfile_apps.go index 0823cbe..ae0eebf 100644 --- a/model/dotfile_apps.go +++ b/model/dotfile_apps.go @@ -305,6 +305,7 @@ func (b *BaseApp) Save(ctx context.Context, files map[string]string, isDryRun bo // In dry-run mode, print the diff instead of writing files fmt.Printf("\nšŸ“„ %s:\n", expandedPath) // TODO: add pretty output for changes + fmt.Println(dms.PrettyPrint(changes)) continue } From 63ebf686dd7f820c09c1fed73f54929639df0caa Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sat, 6 Sep 2025 20:41:25 +0800 Subject: [PATCH 6/6] fix(model): improve diff trim to remove additional control characters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend bytes.Trim to remove \x00, \x0c (form feed), \x0e (Shift Out), and \x0f (Shift In) - Add content comparison check in Save to skip identical files - Update test assertions for proper content handling šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- model/diff.go | 2 +- model/dotfile_apps.go | 5 ++++- model/dotfile_apps_test.go | 6 +++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/model/diff.go b/model/diff.go index 63260ce..99ed2fa 100644 --- a/model/diff.go +++ b/model/diff.go @@ -157,7 +157,7 @@ func (s *diffMergeService) ApplyDiff(baseContent string, changes []diffmatchpatc result = append(result, bytes.Join(additions, nil)...) } - return bytes.Trim(result, "\x00"), nil + return bytes.Trim(result, "\x00\x0c\x0e\x0f"), nil } // PrettyPrint renders diff changes in a beautiful format using pterm (shows added lines only) diff --git a/model/dotfile_apps.go b/model/dotfile_apps.go index ae0eebf..82fb603 100644 --- a/model/dotfile_apps.go +++ b/model/dotfile_apps.go @@ -266,7 +266,6 @@ func (b *BaseApp) Backup(ctx context.Context, paths []string, isDryRun bool) err // Save writes new content for files, using diff to check for actual differences func (b *BaseApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error { - dms := NewDiffMergeService() for path, newContent := range files { @@ -285,6 +284,10 @@ func (b *BaseApp) Save(ctx context.Context, files map[string]string, isDryRun bo continue } + if existingContent == newContent { + continue + } + localObj, err := dms.ConvertToEncodedObject(existingContent) if err != nil { return err diff --git a/model/dotfile_apps_test.go b/model/dotfile_apps_test.go index be8d338..211930d 100644 --- a/model/dotfile_apps_test.go +++ b/model/dotfile_apps_test.go @@ -401,7 +401,7 @@ func TestBaseApp_Save(t *testing.T) { // Check that file was created savedContent, err := os.ReadFile(testFile) require.NoError(t, err) - assert.Equal(t, testContent, string(savedContent)) + assert.EqualValues(t, testContent, string(savedContent)) }) t.Run("save to existing file with different content", func(t *testing.T) { @@ -423,7 +423,7 @@ func TestBaseApp_Save(t *testing.T) { // Check that file was updated savedContent, err := os.ReadFile(testFile) require.NoError(t, err) - assert.Equal(t, newContent, string(savedContent)) + assert.Equal(t, originalContent+newContent, string(savedContent)) }) t.Run("save identical content skips file", func(t *testing.T) { @@ -469,7 +469,7 @@ func TestBaseApp_Save(t *testing.T) { // Check that directories were created and file was saved savedContent, err := os.ReadFile(nestedFile) require.NoError(t, err) - assert.Equal(t, content, string(savedContent)) + assert.EqualValues(t, content, string(savedContent)) }) t.Run("save with tilde path", func(t *testing.T) {