Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions commands/dotfiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
},
},
Expand Down
206 changes: 156 additions & 50 deletions commands/dotfiles_pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,129 @@ 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"
"go.opentelemetry.io/otel/trace"
)

type dotfilePullFileResult struct {
path string
isSuccess bool
isSkipped bool
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()
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 {
Expand All @@ -34,12 +144,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()
}
Comment on lines +147 to 169
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current logic for handling the --apps flag can lead to unexpected behavior. If a user provides only invalid app names, the command falls back to pulling dotfiles for all applications instead of none. This is not intuitive. The logic should be refactored to handle specified apps, warn about invalid ones, and only use all apps when the flag is not provided at all. This would be more consistent with the push command's behavior.

	// Initialize app handlers and filter based on apps parameter
	var filter *model.DotfileFilter
	allAppsMap := model.GetAllAppsMap()
	appHandlers := make(map[model.DotfileAppName]model.DotfileApp)

	if len(apps) > 0 {
		filter = &model.DotfileFilter{
			Apps: apps,
		}
		// Only include specified apps
		for _, appNameStr := range apps {
			appName := model.DotfileAppName(appNameStr)
			if appHandler, exists := allAppsMap[appName]; exists {
				appHandlers[appName] = appHandler
			} else {
				logrus.Warnf("Unknown app specified: '%s', it will be ignored.", appNameStr)
			}
		}
	} else {
		// If no apps are specified, use all.
		appHandlers = allAppsMap
	}


// Fetch dotfiles from server
Expand All @@ -56,31 +182,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
Expand All @@ -90,7 +196,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 {
Expand All @@ -102,6 +207,8 @@ func pullDotfiles(c *cli.Context) error {
var selectedRecord *model.DotfileRecord
var latestRecord *model.DotfileRecord

result[appName] = make([]dotfilePullFileResult, 0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This initialization is inside the loop over files (for _, file := range appData.Files). This will cause the results slice for an app to be reset for each file, meaning only the status of the last file will be recorded. A cleaner solution would be to move this initialization to before the file loop (e.g., to line 93). However, to provide a direct suggestion here, you can make the initialization conditional to prevent re-initialization.

			if result[appName] == nil {
				result[appName] = make([]dotfilePullFileResult, 0)
			}


for i := range file.Records {
record := &file.Records[i]

Expand Down Expand Up @@ -130,7 +237,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 {
Expand All @@ -150,7 +256,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)
Expand All @@ -162,38 +271,35 @@ 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)
}

// 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)
totalFailed += len(filesToUpdate)
for f := range filesToUpdate {
results = append(results, dotfilePullFileResult{
path: f,
isFailed: true,
})
}
} else {
totalProcessed += len(filesToUpdate)
for f := range filesToUpdate {
results = append(results, dotfilePullFileResult{
path: f,
isSuccess: true,
})
}
}
result[appName] = append(result[appName], results...)
}

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)
fmt.Println("\n✅ All dotfiles are up to date")
fmt.Printf("🔄 Skipped: %d files (already identical)\n", totalSkipped)
} 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 totalSkipped > 0 {
fmt.Printf("🔄 Skipped: %d files (already identical)\n", totalSkipped)
}
if totalFailed > 0 {
fmt.Printf("⚠️ Failed: %d files\n", totalFailed)
}
}
// Print the results
printPullResults(result, dryRun)

return nil
}
23 changes: 23 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,49 @@ 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
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/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
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/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
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
Expand All @@ -62,6 +83,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
Expand All @@ -70,4 +92,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
)
Loading