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
8 changes: 8 additions & 0 deletions internal/status/gather.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}

Expand Down
6 changes: 6 additions & 0 deletions internal/status/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
}
Expand Down
74 changes: 61 additions & 13 deletions internal/stow/drift.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package stow

import (
"bytes"
"fmt"
"os"
"path/filepath"
Expand All @@ -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.
Expand Down Expand Up @@ -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
}

Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Comment on lines +237 to +240
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Silent false negative for large same-size files

When two files both exceed 10 MB and happen to have identical sizes, this block returns false (i.e. "no drift"), even if their contents actually differ. A config file that was edited so that the byte count was preserved — characters replaced without adding or removing bytes — would be silently missed. For a dotfiles manager, that scenario is common (e.g. editing a value in-place in a config).

Because the size equality is already checked on line 233, this skip only applies to the narrow case of equal-size files above 10 MB. It would be safer to return a distinct "unknown" sentinel or at least add a comment that explicitly warns callers that content drift may go undetected for large same-size files.

Suggested change
// Skip very large files (>10MB)
if sourceInfo.Size() > 10*1024*1024 {
return false
}
// Skip very large files (>10MB): content comparison is skipped for performance.
// NOTE: files with the same size but different content will NOT be detected as
// having content drift. Callers should be aware of this false-negative.
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
Expand Down
75 changes: 75 additions & 0 deletions internal/stow/symlink.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +161 to +163
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Symlinks-to-directories treated as orphan files

entry.IsDir() returns false for symlinks whose target is a directory (because the type bit is ModeSymlink, not ModeDir). Such an entry will fall through the entry.IsDir() guard, reach the symlink-check block, and — if the symlink does not point back into the config path — be appended to orphans as a file-orphan even though it is actually a directory symlink.

This can produce spurious orphan reports for directories that are symlinked into a managed folder by another tool (e.g. a plugin manager placing a symlinked directory beside managed dotfiles).

Suggested change
for _, entry := range entries {
if entry.IsDir() {
continue
for _, entry := range entries {
// Skip regular directories and symlinks-to-directories
if entry.IsDir() {
continue
}
entryPath := filepath.Join(targetDir, entry.Name())
if entry.Type()&os.ModeSymlink != 0 {
if fi, err := os.Stat(entryPath); err == nil && fi.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
}
Loading
Loading