From 39c19246a7422106b5e4a343bb6c640aa48d3211 Mon Sep 17 00:00:00 2001 From: rust <51134175+nvandessel@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:39:38 -0800 Subject: [PATCH] feat: enhance drift visualization with content drift and orphan detection (go4dot-ov1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new drift detection capabilities: - ContentDriftFiles: detect conflict files where dest content differs from source - OrphanFiles: detect files in dest managed dirs not tracked by source Enhanced visualization across CLI and dashboard: - Status output shows content drift (≠) and untracked (?) counts - Details panel shows DRIFT STATUS summary section - File tree shows inline drift indicators (≠ for content drift, ? for orphans) - Conflict files annotated with [content differs] when content has drifted Co-Authored-By: Claude Opus 4.6 --- internal/status/gather.go | 8 ++ internal/status/render.go | 6 ++ internal/stow/drift.go | 74 +++++++++++--- internal/stow/symlink.go | 75 ++++++++++++++ internal/ui/dashboard/details_panel.go | 129 +++++++++++++++++++++++-- 5 files changed, 270 insertions(+), 22 deletions(-) diff --git a/internal/status/gather.go b/internal/status/gather.go index 5788a23..df7b2b4 100644 --- a/internal/status/gather.go +++ b/internal/status/gather.go @@ -29,6 +29,8 @@ type ConfigStatus struct { NewFiles int `json:"new_files,omitempty"` MissingFiles int `json:"missing_files,omitempty"` Conflicts int `json:"conflicts,omitempty"` + ContentDrift int `json:"content_drift,omitempty"` + Orphans int `json:"orphans,omitempty"` } // DependencyStatus holds a summary of dependency checking. @@ -167,10 +169,16 @@ func (g *Gatherer) Gather(opts GatherOptions) (*Overview, error) { cs.NewFiles = len(dr.NewFiles) cs.MissingFiles = len(dr.MissingFiles) cs.Conflicts = len(dr.ConflictFiles) + cs.ContentDrift = len(dr.ContentDriftFiles) } else { cs.Status = SyncStatusSynced } + // Orphans are informational - set regardless of drift status + if dr, ok := driftMap[c.Name]; ok { + cs.Orphans = len(dr.OrphanFiles) + } + overview.Configs = append(overview.Configs, cs) } diff --git a/internal/status/render.go b/internal/status/render.go index 79fc73c..c0d19d7 100644 --- a/internal/status/render.go +++ b/internal/status/render.go @@ -187,6 +187,12 @@ func driftDetails(cs ConfigStatus) string { if cs.Conflicts > 0 { parts = append(parts, fmt.Sprintf("!%d conflicts", cs.Conflicts)) } + if cs.ContentDrift > 0 { + parts = append(parts, fmt.Sprintf("≠%d content drift", cs.ContentDrift)) + } + if cs.Orphans > 0 { + parts = append(parts, fmt.Sprintf("?%d untracked", cs.Orphans)) + } if len(parts) == 0 { return "" } diff --git a/internal/stow/drift.go b/internal/stow/drift.go index 95a5ff2..29aa358 100644 --- a/internal/stow/drift.go +++ b/internal/stow/drift.go @@ -1,6 +1,7 @@ package stow import ( + "bytes" "fmt" "os" "path/filepath" @@ -11,23 +12,27 @@ import ( // DriftResult represents the drift status for a single config. type DriftResult struct { - ConfigName string // Name of the config (e.g., "nvim") - ConfigPath string // Path within dotfiles (e.g., "nvim") - CurrentCount int // Current file count in the config directory - StoredCount int // File count stored in state - HasDrift bool // True if counts differ or files are missing/conflicting - NewFiles []string // Files in dotfiles but not symlinked (populated by FullDriftCheck) - MissingFiles []string // Symlinks pointing to deleted files - ConflictFiles []string // Files that exist in home but aren't symlinks + ConfigName string // Name of the config (e.g., "nvim") + ConfigPath string // Path within dotfiles (e.g., "nvim") + CurrentCount int // Current file count in the config directory + StoredCount int // File count stored in state + HasDrift bool // True if counts differ or files are missing/conflicting + NewFiles []string // Files in dotfiles but not symlinked (populated by FullDriftCheck) + MissingFiles []string // Symlinks pointing to deleted files + ConflictFiles []string // Files that exist in home but aren't symlinks + ContentDriftFiles []string // Conflict files where dest content differs from source + OrphanFiles []string // Files in dest managed dirs not tracked by source } // DriftSummary provides an overview of drift across all configs. type DriftSummary struct { - TotalConfigs int // Total number of configs analyzed - DriftedConfigs int // Number of configs with detected drift - TotalNewFiles int // Total number of new files across all configs - Results []DriftResult // Detailed results for each config - RemovedConfigs []string // Configs in state but not in current config + TotalConfigs int // Total number of configs analyzed + DriftedConfigs int // Number of configs with detected drift + TotalNewFiles int // Total number of new files across all configs + TotalContentDrift int // Total content-drifted conflict files + TotalOrphans int // Total untracked files in managed dirs + Results []DriftResult // Detailed results for each config + RemovedConfigs []string // Configs in state but not in current config } // HasDrift returns true if any config has drift or if there are removed configs. @@ -138,6 +143,9 @@ func FullDriftCheckWithHome(cfg *config.Config, dotfilesPath, home string, st *s // File exists but is not a symlink - conflict result.ConflictFiles = append(result.ConflictFiles, relPath) + if hasContentDrift(path, targetPath) { + result.ContentDriftFiles = append(result.ContentDriftFiles, relPath) + } return nil } @@ -156,6 +164,9 @@ func FullDriftCheckWithHome(cfg *config.Config, dotfilesPath, home string, st *s // If symlink points to wrong location, count as conflict if linkDest != path { result.ConflictFiles = append(result.ConflictFiles, relPath) + if hasContentDrift(path, targetPath) { + result.ContentDriftFiles = append(result.ContentDriftFiles, relPath) + } } return nil @@ -169,6 +180,7 @@ func FullDriftCheckWithHome(cfg *config.Config, dotfilesPath, home string, st *s // We can do this by walking the target directories that we know about // from the current config structure. result.MissingFiles = findOrphanedSymlinks(configPath, home) + result.OrphanFiles = findOrphanFiles(configPath, home) result.HasDrift = len(result.NewFiles) > 0 || len(result.ConflictFiles) > 0 || len(result.MissingFiles) > 0 results = append(results, result) @@ -197,12 +209,48 @@ func FullDriftCheckWithHome(cfg *config.Config, dotfilesPath, home string, st *s if r.HasDrift { summary.DriftedConfigs++ summary.TotalNewFiles += len(r.NewFiles) + summary.TotalContentDrift += len(r.ContentDriftFiles) } + summary.TotalOrphans += len(r.OrphanFiles) } return summary, nil } +// hasContentDrift compares the content of source and dest files. +// Returns true if content differs, false if identical or on error. +func hasContentDrift(sourcePath, destPath string) bool { + sourceInfo, err := os.Stat(sourcePath) + if err != nil { + return false + } + destInfo, err := os.Stat(destPath) + if err != nil { + return false + } + + // Different sizes means different content + if sourceInfo.Size() != destInfo.Size() { + return true + } + + // Skip very large files (>10MB) + if sourceInfo.Size() > 10*1024*1024 { + return false + } + + sourceContent, err := os.ReadFile(sourcePath) + if err != nil { + return false + } + destContent, err := os.ReadFile(destPath) + if err != nil { + return false + } + + return !bytes.Equal(sourceContent, destContent) +} + // GetDriftedConfigs returns only configs that have drift. func GetDriftedConfigs(results []DriftResult) []DriftResult { var drifted []DriftResult diff --git a/internal/stow/symlink.go b/internal/stow/symlink.go index f1e1d95..ea78dfc 100644 --- a/internal/stow/symlink.go +++ b/internal/stow/symlink.go @@ -117,3 +117,78 @@ func findOrphanedSymlinks(configPath, home string) []string { return orphans } + +// findOrphanFiles finds files in home managed directories that aren't tracked +// by the config source. Only checks directories where the config has files +// (not parent traversal directories). Skips root directory to avoid scanning +// the entire home. +func findOrphanFiles(configPath, home string) []string { + var orphans []string + + // Build set of expected file paths (relative to config root) + expectedFiles := make(map[string]bool) + _ = filepath.Walk(configPath, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + relPath, err := filepath.Rel(configPath, path) + if err == nil { + expectedFiles[relPath] = true + } + return nil + }) + + // Get directories that directly contain config files + fileDirs := make(map[string]bool) + for relPath := range expectedFiles { + dir := filepath.Dir(relPath) + fileDirs[dir] = true + } + + // Walk these directories in home, find unmanaged files + for relDir := range fileDirs { + // Skip root directory to avoid scanning entire home + if relDir == "." { + continue + } + + targetDir := filepath.Join(home, relDir) + entries, err := os.ReadDir(targetDir) + if err != nil { + continue + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + entryRelPath := filepath.Join(relDir, entry.Name()) + + // Skip if this file is expected from the config + if expectedFiles[entryRelPath] { + continue + } + + // Skip symlinks pointing into our config dir (handled by MissingFiles) + entryPath := filepath.Join(targetDir, entry.Name()) + if entry.Type()&os.ModeSymlink != 0 { + linkDest, err := os.Readlink(entryPath) + if err == nil { + if !filepath.IsAbs(linkDest) { + linkDest = filepath.Join(targetDir, linkDest) + } + linkDest = filepath.Clean(linkDest) + relToConfig, err := filepath.Rel(configPath, linkDest) + if err == nil && !strings.HasPrefix(relToConfig, "..") && relToConfig != ".." { + continue + } + } + } + + orphans = append(orphans, entryRelPath) + } + } + + return orphans +} diff --git a/internal/ui/dashboard/details_panel.go b/internal/ui/dashboard/details_panel.go index ec96e25..5d00171 100644 --- a/internal/ui/dashboard/details_panel.go +++ b/internal/ui/dashboard/details_panel.go @@ -200,6 +200,36 @@ func (p *DetailsPanel) renderConfigDetails() string { lines = append(lines, "") } + // Get drift result for enhanced display + var driftResult *stow.DriftResult + if p.state.DriftSummary != nil { + driftResult = p.state.DriftSummary.ResultByName(cfg.Name) + } + + // Show drift status summary + if driftResult != nil && (driftResult.HasDrift || len(driftResult.OrphanFiles) > 0) { + lines = append(lines, headerStyle.Render("DRIFT STATUS")) + var driftParts []string + if len(driftResult.NewFiles) > 0 { + driftParts = append(driftParts, okStyle.Render(fmt.Sprintf("+%d new", len(driftResult.NewFiles)))) + } + if len(driftResult.MissingFiles) > 0 { + driftParts = append(driftParts, errStyle.Render(fmt.Sprintf("-%d missing", len(driftResult.MissingFiles)))) + } + if len(driftResult.ConflictFiles) > 0 { + conflictText := fmt.Sprintf("!%d conflicts", len(driftResult.ConflictFiles)) + if len(driftResult.ContentDriftFiles) > 0 { + conflictText += fmt.Sprintf(" (%d content differs)", len(driftResult.ContentDriftFiles)) + } + driftParts = append(driftParts, warnStyle.Render(conflictText)) + } + if len(driftResult.OrphanFiles) > 0 { + driftParts = append(driftParts, subtleStyle.Render(fmt.Sprintf("?%d untracked", len(driftResult.OrphanFiles)))) + } + lines = append(lines, " "+strings.Join(driftParts, ", ")) + lines = append(lines, "") + } + if linkStatus != nil { linked := linkStatus.LinkedCount total := linkStatus.TotalCount @@ -213,6 +243,13 @@ func (p *DetailsPanel) renderConfigDetails() string { // Build and render file tree with proper connectors tree := buildFileTree(linkStatus.Files) + + // Augment tree with drift information + if driftResult != nil { + addOrphansToTree(tree, driftResult.OrphanFiles) + markContentDriftInTree(tree, driftResult.ContentDriftFiles) + } + treeLines := renderFileTree(tree, "", true, okStyle, warnStyle, errStyle, subtleStyle) lines = append(lines, treeLines...) lines = append(lines, "") @@ -258,11 +295,13 @@ func (p *DetailsPanel) renderConfigDetails() string { // fileTreeNode represents a node in the file tree (either a directory or file) type fileTreeNode struct { - name string - isDir bool - isLinked bool - issue string - children map[string]*fileTreeNode + name string + isDir bool + isLinked bool + issue string + isOrphan bool // File in dest not tracked by source + hasContentDrift bool // Conflict file with different content from source + children map[string]*fileTreeNode } // buildFileTree creates a tree structure from flat file paths @@ -363,8 +402,12 @@ func renderFileTree(node *fileTreeNode, prefix string, isRoot bool, okStyle, war } else { // File node - choose status icon var icon string - if child.isLinked { + if child.isOrphan { + icon = subtleStyle.Render("?") + } else if child.isLinked { icon = okStyle.Render("✓") + } else if child.hasContentDrift { + icon = errStyle.Render("≠") } else if strings.Contains(strings.ToLower(child.issue), "conflict") || strings.Contains(strings.ToLower(child.issue), "exists") || strings.Contains(strings.ToLower(child.issue), "elsewhere") { @@ -375,9 +418,15 @@ func renderFileTree(node *fileTreeNode, prefix string, isRoot bool, okStyle, war lines = append(lines, linePrefix+subtleStyle.Render(connector)+" "+icon+" "+name) - // Show issue description for unlinked files - if !child.isLinked && child.issue != "" { - lines = append(lines, subtleStyle.Render(childPrefix+"→ "+child.issue)) + // Show issue description + if child.isOrphan { + lines = append(lines, subtleStyle.Render(childPrefix+"→ untracked (not in source)")) + } else if !child.isLinked && child.issue != "" { + issueText := child.issue + if child.hasContentDrift { + issueText += " [content differs]" + } + lines = append(lines, subtleStyle.Render(childPrefix+"→ "+issueText)) } } } @@ -584,6 +633,68 @@ func (p *DetailsPanel) renderExternalDetails() string { return strings.Join(lines, "\n") } +// addOrphansToTree adds orphan file nodes to the file tree +func addOrphansToTree(root *fileTreeNode, orphanFiles []string) { + for _, orphanPath := range orphanFiles { + parts := strings.Split(orphanPath, string(filepath.Separator)) + current := root + + for i, part := range parts { + if part == "" { + continue + } + isLast := i == len(parts)-1 + + if current.children == nil { + current.children = make(map[string]*fileTreeNode) + } + + child, exists := current.children[part] + if !exists { + child = &fileTreeNode{ + name: part, + isDir: !isLast, + children: make(map[string]*fileTreeNode), + } + current.children[part] = child + } + + if isLast { + child.isOrphan = true + child.isDir = false + } + + current = child + } + } +} + +// markContentDriftInTree marks nodes in the tree that have content drift +func markContentDriftInTree(root *fileTreeNode, contentDriftFiles []string) { + if len(contentDriftFiles) == 0 { + return + } + driftSet := make(map[string]bool, len(contentDriftFiles)) + for _, f := range contentDriftFiles { + driftSet[f] = true + } + markContentDriftRecursive(root, "", driftSet) +} + +func markContentDriftRecursive(node *fileTreeNode, prefix string, driftSet map[string]bool) { + for name, child := range node.children { + fullPath := name + if prefix != "" { + fullPath = filepath.Join(prefix, name) + } + if child.isDir { + markContentDriftRecursive(child, fullPath, driftSet) + } else if driftSet[fullPath] { + child.hasContentDrift = true + } + } +} + // UpdateState updates the panel's state reference func (p *DetailsPanel) UpdateState(state State) { p.state = state