diff --git a/model/dotfile_apps.go b/model/dotfile_apps.go index 43f4bf1..169302c 100644 --- a/model/dotfile_apps.go +++ b/model/dotfile_apps.go @@ -67,6 +67,7 @@ func GetAllAppsMap() map[DotfileAppName]DotfileApp { type DotfileApp interface { Name() string GetConfigPaths() []string + GetIncludeDirectives() []IncludeDirective CollectDotfiles(ctx context.Context) ([]DotfileItem, error) IsEqual(ctx context.Context, files map[string]string) (map[string]bool, error) Backup(ctx context.Context, paths []string, isDryRun bool) error @@ -78,6 +79,12 @@ type BaseApp struct { name string } +// GetIncludeDirectives returns an empty slice by default. +// Apps that support include directives should override this method. +func (b *BaseApp) GetIncludeDirectives() []IncludeDirective { + return nil +} + func (b *BaseApp) expandPath(path string) (string, error) { if strings.HasPrefix(path, "~") { homeDir, err := os.UserHomeDir() @@ -317,8 +324,10 @@ func (b *BaseApp) Save(ctx context.Context, files map[string]string, isDryRun bo // Read existing content if file exists var existingContent string + fileExists := false if existingBytes, err := os.ReadFile(expandedPath); err == nil { existingContent = string(existingBytes) + fileExists = true } else if !os.IsNotExist(err) { slog.Warn("Failed to read existing file", slog.String("path", expandedPath), slog.Any("err", err)) continue @@ -328,6 +337,25 @@ func (b *BaseApp) Save(ctx context.Context, files map[string]string, isDryRun bo continue } + // For new files, write directly without diff-merge + if !fileExists { + dir := filepath.Dir(expandedPath) + if err := os.MkdirAll(dir, 0755); err != nil { + slog.Warn("Failed to create directory", slog.String("dir", dir), slog.Any("err", err)) + continue + } + if isDryRun { + fmt.Printf("\nšŸ“„ %s (new file):\n%s\n", expandedPath, newContent) + continue + } + if err := os.WriteFile(expandedPath, []byte(newContent), 0644); err != nil { + slog.Warn("Failed to save file", slog.String("path", expandedPath), slog.Any("err", err)) + } else { + slog.Info("Saved new content", slog.String("path", expandedPath)) + } + continue + } + localObj, err := dms.ConvertToEncodedObject(existingContent) if err != nil { return err diff --git a/model/dotfile_bash.go b/model/dotfile_bash.go index 15d6317..82d342d 100644 --- a/model/dotfile_bash.go +++ b/model/dotfile_bash.go @@ -24,7 +24,40 @@ func (b *BashApp) GetConfigPaths() []string { } } +func (b *BashApp) GetIncludeDirectives() []IncludeDirective { + return []IncludeDirective{ + { + OriginalPath: "~/.bashrc", + ShelltimePath: "~/.bashrc.shelltime", + IncludeLine: "[[ -f ~/.bashrc.shelltime ]] && source ~/.bashrc.shelltime", + CheckString: ".bashrc.shelltime", + }, + { + OriginalPath: "~/.bash_profile", + ShelltimePath: "~/.bash_profile.shelltime", + IncludeLine: "[[ -f ~/.bash_profile.shelltime ]] && source ~/.bash_profile.shelltime", + CheckString: ".bash_profile.shelltime", + }, + { + OriginalPath: "~/.bash_aliases", + ShelltimePath: "~/.bash_aliases.shelltime", + IncludeLine: "[[ -f ~/.bash_aliases.shelltime ]] && source ~/.bash_aliases.shelltime", + CheckString: ".bash_aliases.shelltime", + }, + { + OriginalPath: "~/.bash_logout", + ShelltimePath: "~/.bash_logout.shelltime", + IncludeLine: "[[ -f ~/.bash_logout.shelltime ]] && source ~/.bash_logout.shelltime", + CheckString: ".bash_logout.shelltime", + }, + } +} + func (b *BashApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) { skipIgnored := true - return b.CollectFromPaths(ctx, b.Name(), b.GetConfigPaths(), &skipIgnored) -} \ No newline at end of file + return b.CollectWithIncludeSupport(ctx, b.Name(), b.GetConfigPaths(), &skipIgnored, b.GetIncludeDirectives()) +} + +func (b *BashApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error { + return b.SaveWithIncludeSupport(ctx, files, isDryRun, b.GetIncludeDirectives()) +} diff --git a/model/dotfile_fish.go b/model/dotfile_fish.go index ae277aa..c2d22d4 100644 --- a/model/dotfile_fish.go +++ b/model/dotfile_fish.go @@ -23,7 +23,22 @@ func (f *FishApp) GetConfigPaths() []string { } } +func (f *FishApp) GetIncludeDirectives() []IncludeDirective { + return []IncludeDirective{ + { + OriginalPath: "~/.config/fish/config.fish", + ShelltimePath: "~/.config/fish/config.fish.shelltime", + IncludeLine: "test -f ~/.config/fish/config.fish.shelltime; and source ~/.config/fish/config.fish.shelltime", + CheckString: "config.fish.shelltime", + }, + } +} + func (f *FishApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) { skipIgnored := true - return f.CollectFromPaths(ctx, f.Name(), f.GetConfigPaths(), &skipIgnored) -} \ No newline at end of file + return f.CollectWithIncludeSupport(ctx, f.Name(), f.GetConfigPaths(), &skipIgnored, f.GetIncludeDirectives()) +} + +func (f *FishApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error { + return f.SaveWithIncludeSupport(ctx, files, isDryRun, f.GetIncludeDirectives()) +} diff --git a/model/dotfile_git.go b/model/dotfile_git.go index e976412..d12d4ec 100644 --- a/model/dotfile_git.go +++ b/model/dotfile_git.go @@ -24,7 +24,28 @@ func (g *GitApp) GetConfigPaths() []string { } } +func (g *GitApp) GetIncludeDirectives() []IncludeDirective { + return []IncludeDirective{ + { + OriginalPath: "~/.gitconfig", + ShelltimePath: "~/.gitconfig.shelltime", + IncludeLine: "[include]\n path = ~/.gitconfig.shelltime", + CheckString: ".gitconfig.shelltime", + }, + { + OriginalPath: "~/.config/git/config", + ShelltimePath: "~/.config/git/config.shelltime", + IncludeLine: "[include]\n path = ~/.config/git/config.shelltime", + CheckString: "git/config.shelltime", + }, + } +} + func (g *GitApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) { skipIgnored := true - return g.CollectFromPaths(ctx, g.Name(), g.GetConfigPaths(), &skipIgnored) -} \ No newline at end of file + return g.CollectWithIncludeSupport(ctx, g.Name(), g.GetConfigPaths(), &skipIgnored, g.GetIncludeDirectives()) +} + +func (g *GitApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error { + return g.SaveWithIncludeSupport(ctx, files, isDryRun, g.GetIncludeDirectives()) +} diff --git a/model/dotfile_include.go b/model/dotfile_include.go new file mode 100644 index 0000000..38de620 --- /dev/null +++ b/model/dotfile_include.go @@ -0,0 +1,270 @@ +package model + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "strings" +) + +// IncludeDirective defines how a config file includes its shelltime-managed version. +// When pushing/pulling dotfiles, the original config file gets an include line added +// at the top that sources the .shelltime version. The actual synced content lives +// in the .shelltime file. +type IncludeDirective struct { + OriginalPath string // tilde path to original config, e.g., "~/.gitconfig" + ShelltimePath string // tilde path to shelltime file, e.g., "~/.gitconfig.shelltime" + IncludeLine string // The include line(s) to add at the top of the original file + CheckString string // Substring to check if include already exists in file content +} + +// ensureIncludeSetup ensures the include directive is properly set up for push. +// - If the original file has no include line and no .shelltime file exists: +// +// copies content to .shelltime and adds include line to original. +// +// - If the include line is missing: adds it to the original. +// - If .shelltime file is missing but include line exists: extracts content from original. +func (b *BaseApp) ensureIncludeSetup(directive *IncludeDirective) error { + expandedOriginal, err := b.expandPath(directive.OriginalPath) + if err != nil { + return err + } + expandedShelltime, err := b.expandPath(directive.ShelltimePath) + if err != nil { + return err + } + + // Check if original file exists + originalBytes, err := os.ReadFile(expandedOriginal) + if err != nil { + if os.IsNotExist(err) { + return nil // Original doesn't exist, nothing to do + } + return err + } + + content := string(originalBytes) + hasInclude := strings.Contains(content, directive.CheckString) + _, shelltimeErr := os.Stat(expandedShelltime) + shelltimeExists := shelltimeErr == nil + + if hasInclude && shelltimeExists { + return nil // Already set up + } + + if !hasInclude && !shelltimeExists { + // First time setup: copy content to .shelltime, add include to original + if err := writeFileWithDir(expandedShelltime, content); err != nil { + return err + } + newOriginal := directive.IncludeLine + "\n" + content + return os.WriteFile(expandedOriginal, []byte(newOriginal), 0644) + } + + if !hasInclude { + // .shelltime exists but include line missing from original - add it + newOriginal := directive.IncludeLine + "\n" + content + return os.WriteFile(expandedOriginal, []byte(newOriginal), 0644) + } + + // hasInclude && !shelltimeExists + // Include line exists but .shelltime file was deleted - extract content + contentWithoutInclude := removeIncludeLines(content, directive) + return writeFileWithDir(expandedShelltime, contentWithoutInclude) +} + +// ensureIncludeLineInFile ensures the include line exists in the original config file. +// Used during pull to set up the include before saving the .shelltime file. +func (b *BaseApp) ensureIncludeLineInFile(directive *IncludeDirective, isDryRun bool) error { + expandedOriginal, err := b.expandPath(directive.OriginalPath) + if err != nil { + return err + } + + // Read original file (or treat as empty if it doesn't exist) + var content string + if data, err := os.ReadFile(expandedOriginal); err == nil { + content = string(data) + } else if !os.IsNotExist(err) { + return err + } + + // Check if include already exists + if strings.Contains(content, directive.CheckString) { + return nil + } + + if isDryRun { + slog.Info("[DRY RUN] Would add include line", slog.String("file", expandedOriginal)) + return nil + } + + // Add include line at top + var newContent string + if content == "" { + newContent = directive.IncludeLine + "\n" + } else { + newContent = directive.IncludeLine + "\n" + content + } + + return writeFileWithDir(expandedOriginal, newContent) +} + +// CollectWithIncludeSupport collects dotfiles, handling include directives. +// For paths with include directives, it ensures the include setup and collects from .shelltime files. +// For other paths (directories, non-includable files), it uses standard collection. +func (b *BaseApp) CollectWithIncludeSupport(ctx context.Context, appName string, paths []string, skipIgnored *bool, directives []IncludeDirective) ([]DotfileItem, error) { + // Build directive lookup by expanded original path + directiveMap := make(map[string]*IncludeDirective) + for i, d := range directives { + expanded, err := b.expandPath(d.OriginalPath) + if err == nil { + directiveMap[expanded] = &directives[i] + } + } + + var allDotfiles []DotfileItem + var nonIncludePaths []string + + for _, path := range paths { + expanded, err := b.expandPath(path) + if err != nil { + nonIncludePaths = append(nonIncludePaths, path) + continue + } + + directive, found := directiveMap[expanded] + if !found { + nonIncludePaths = append(nonIncludePaths, path) + continue + } + + // This path has include support + // Check if original file exists + if _, err := os.Stat(expanded); err != nil { + slog.Debug("Original file not found, skipping include setup", slog.String("path", path)) + continue + } + + // Ensure include setup (adds include line, creates .shelltime file if needed) + if err := b.ensureIncludeSetup(directive); err != nil { + slog.Warn("Failed to ensure include setup", slog.String("path", path), slog.Any("err", err)) + continue + } + + // Collect from .shelltime file instead of original + items, err := b.CollectFromPaths(ctx, appName, []string{directive.ShelltimePath}, skipIgnored) + if err != nil { + slog.Warn("Failed to collect from shelltime file", slog.String("path", directive.ShelltimePath), slog.Any("err", err)) + continue + } + allDotfiles = append(allDotfiles, items...) + } + + // Collect non-include paths normally (directories, non-includable files) + if len(nonIncludePaths) > 0 { + items, err := b.CollectFromPaths(ctx, appName, nonIncludePaths, skipIgnored) + if err != nil { + return allDotfiles, err + } + allDotfiles = append(allDotfiles, items...) + } + + return allDotfiles, nil +} + +// SaveWithIncludeSupport saves files, ensuring include directives for .shelltime files. +// For .shelltime paths that match a known directive, it also ensures the include line +// exists in the original config file. +func (b *BaseApp) SaveWithIncludeSupport(ctx context.Context, files map[string]string, isDryRun bool, directives []IncludeDirective) error { + // Build directive lookup by shelltime path (both tilde and expanded) + shelltimeMap := make(map[string]*IncludeDirective) + for i, d := range directives { + shelltimeMap[d.ShelltimePath] = &directives[i] + expanded, err := b.expandPath(d.ShelltimePath) + if err == nil { + shelltimeMap[expanded] = &directives[i] + } + } + + // Separate shelltime files (direct overwrite) from non-shelltime files (diff-merge) + nonShelltimeFiles := make(map[string]string) + for filePath, content := range files { + if directive, found := shelltimeMap[filePath]; found { + // Ensure include line in the original config + if err := b.ensureIncludeLineInFile(directive, isDryRun); err != nil { + slog.Warn("Failed to ensure include line", slog.String("path", filePath), slog.Any("err", err)) + } + // Write .shelltime file directly (server-managed, no diff-merge) + expanded, err := b.expandPath(filePath) + if err != nil { + slog.Warn("Failed to expand path", slog.String("path", filePath), slog.Any("err", err)) + continue + } + if isDryRun { + slog.Info("[DRY RUN] Would write shelltime file", slog.String("path", expanded)) + continue + } + if err := writeFileWithDir(expanded, content); err != nil { + slog.Warn("Failed to write shelltime file", slog.String("path", expanded), slog.Any("err", err)) + } else { + slog.Info("Saved new content", slog.String("path", expanded)) + } + } else { + nonShelltimeFiles[filePath] = content + } + } + + // Use base Save for non-shelltime files (diff-merge) + if len(nonShelltimeFiles) > 0 { + return b.Save(ctx, nonShelltimeFiles, isDryRun) + } + return nil +} + +// writeFileWithDir writes content to a file, creating parent directories if needed. +func writeFileWithDir(path, content string) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return os.WriteFile(path, []byte(content), 0644) +} + +// removeIncludeLines removes the include directive lines from content. +// First tries to match and remove from the top of the file. +// Falls back to removing any lines containing the check string. +func removeIncludeLines(content string, directive *IncludeDirective) string { + lines := strings.Split(content, "\n") + includeLines := strings.Split(directive.IncludeLine, "\n") + + // Try to find and remove the include lines from the top + if len(lines) >= len(includeLines) { + allMatch := true + for i, il := range includeLines { + if strings.TrimSpace(lines[i]) != strings.TrimSpace(il) { + allMatch = false + break + } + } + if allMatch { + remaining := lines[len(includeLines):] + // Remove leading empty line if present (we add \n after include line) + if len(remaining) > 0 && strings.TrimSpace(remaining[0]) == "" { + remaining = remaining[1:] + } + return strings.Join(remaining, "\n") + } + } + + // Fallback: remove any line containing the check string + var filtered []string + for _, line := range lines { + if !strings.Contains(line, directive.CheckString) { + filtered = append(filtered, line) + } + } + return strings.Join(filtered, "\n") +} diff --git a/model/dotfile_include_test.go b/model/dotfile_include_test.go new file mode 100644 index 0000000..3f5614b --- /dev/null +++ b/model/dotfile_include_test.go @@ -0,0 +1,747 @@ +package model + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRemoveIncludeLines(t *testing.T) { + t.Run("remove single-line include from top", func(t *testing.T) { + content := "[[ -f ~/.bashrc.shelltime ]] && source ~/.bashrc.shelltime\nalias ll='ls -la'\nexport PATH=$PATH:/usr/local/bin" + directive := &IncludeDirective{ + IncludeLine: "[[ -f ~/.bashrc.shelltime ]] && source ~/.bashrc.shelltime", + CheckString: ".bashrc.shelltime", + } + + result := removeIncludeLines(content, directive) + assert.NotContains(t, result, ".bashrc.shelltime") + assert.Contains(t, result, "alias ll='ls -la'") + assert.Contains(t, result, "export PATH") + }) + + t.Run("remove multi-line include from top (git)", func(t *testing.T) { + content := "[include]\n path = ~/.gitconfig.shelltime\n\n[user]\n name = Test" + directive := &IncludeDirective{ + IncludeLine: "[include]\n path = ~/.gitconfig.shelltime", + CheckString: ".gitconfig.shelltime", + } + + result := removeIncludeLines(content, directive) + assert.NotContains(t, result, "[include]") + assert.NotContains(t, result, ".gitconfig.shelltime") + assert.Contains(t, result, "[user]") + assert.Contains(t, result, "name = Test") + }) + + t.Run("fallback to check string removal", func(t *testing.T) { + content := "some line\n[[ -f ~/.bashrc.shelltime ]] && source ~/.bashrc.shelltime\nalias ll='ls -la'" + directive := &IncludeDirective{ + IncludeLine: "[[ -f ~/.bashrc.shelltime ]] && source ~/.bashrc.shelltime", + CheckString: ".bashrc.shelltime", + } + + result := removeIncludeLines(content, directive) + assert.NotContains(t, result, ".bashrc.shelltime") + assert.Contains(t, result, "some line") + assert.Contains(t, result, "alias ll='ls -la'") + }) +} + +func TestBaseApp_ensureIncludeSetup(t *testing.T) { + app := &BaseApp{name: "test"} + + t.Run("first time setup - no include, no shelltime file", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + originalPath := filepath.Join(tmpDir, ".bashrc") + shelltimePath := filepath.Join(tmpDir, ".bashrc.shelltime") + originalContent := "alias ll='ls -la'\nexport PATH=$PATH:/usr/local/bin\n" + err = os.WriteFile(originalPath, []byte(originalContent), 0644) + require.NoError(t, err) + + directive := &IncludeDirective{ + OriginalPath: originalPath, + ShelltimePath: shelltimePath, + IncludeLine: "[[ -f " + shelltimePath + " ]] && source " + shelltimePath, + CheckString: ".bashrc.shelltime", + } + + err = app.ensureIncludeSetup(directive) + require.NoError(t, err) + + // Check .shelltime file was created with original content + shelltimeContent, err := os.ReadFile(shelltimePath) + require.NoError(t, err) + assert.Equal(t, originalContent, string(shelltimeContent)) + + // Check original file has include line at top + updatedOriginal, err := os.ReadFile(originalPath) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(updatedOriginal), directive.IncludeLine)) + assert.Contains(t, string(updatedOriginal), originalContent) + }) + + t.Run("include line missing but shelltime file exists", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + originalPath := filepath.Join(tmpDir, ".bashrc") + shelltimePath := filepath.Join(tmpDir, ".bashrc.shelltime") + + err = os.WriteFile(originalPath, []byte("local stuff\n"), 0644) + require.NoError(t, err) + err = os.WriteFile(shelltimePath, []byte("synced stuff\n"), 0644) + require.NoError(t, err) + + directive := &IncludeDirective{ + OriginalPath: originalPath, + ShelltimePath: shelltimePath, + IncludeLine: "[[ -f " + shelltimePath + " ]] && source " + shelltimePath, + CheckString: ".bashrc.shelltime", + } + + err = app.ensureIncludeSetup(directive) + require.NoError(t, err) + + // Check include line was added to original + updatedOriginal, err := os.ReadFile(originalPath) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(updatedOriginal), directive.IncludeLine)) + assert.Contains(t, string(updatedOriginal), "local stuff") + + // .shelltime file should be unchanged + shelltimeContent, err := os.ReadFile(shelltimePath) + require.NoError(t, err) + assert.Equal(t, "synced stuff\n", string(shelltimeContent)) + }) + + t.Run("include line exists but shelltime file missing", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + originalPath := filepath.Join(tmpDir, ".bashrc") + shelltimePath := filepath.Join(tmpDir, ".bashrc.shelltime") + includeLine := "[[ -f " + shelltimePath + " ]] && source " + shelltimePath + originalContent := includeLine + "\nalias ll='ls -la'\n" + err = os.WriteFile(originalPath, []byte(originalContent), 0644) + require.NoError(t, err) + + directive := &IncludeDirective{ + OriginalPath: originalPath, + ShelltimePath: shelltimePath, + IncludeLine: includeLine, + CheckString: ".bashrc.shelltime", + } + + err = app.ensureIncludeSetup(directive) + require.NoError(t, err) + + // .shelltime file should be created with content minus include line + shelltimeContent, err := os.ReadFile(shelltimePath) + require.NoError(t, err) + assert.NotContains(t, string(shelltimeContent), ".bashrc.shelltime") + assert.Contains(t, string(shelltimeContent), "alias ll='ls -la'") + }) + + t.Run("both exist - no changes needed", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + originalPath := filepath.Join(tmpDir, ".bashrc") + shelltimePath := filepath.Join(tmpDir, ".bashrc.shelltime") + includeLine := "[[ -f " + shelltimePath + " ]] && source " + shelltimePath + + err = os.WriteFile(originalPath, []byte(includeLine+"\nlocal stuff\n"), 0644) + require.NoError(t, err) + err = os.WriteFile(shelltimePath, []byte("synced stuff\n"), 0644) + require.NoError(t, err) + + directive := &IncludeDirective{ + OriginalPath: originalPath, + ShelltimePath: shelltimePath, + IncludeLine: includeLine, + CheckString: ".bashrc.shelltime", + } + + err = app.ensureIncludeSetup(directive) + require.NoError(t, err) + + // Both files should be unchanged + originalResult, err := os.ReadFile(originalPath) + require.NoError(t, err) + assert.Equal(t, includeLine+"\nlocal stuff\n", string(originalResult)) + + shelltimeResult, err := os.ReadFile(shelltimePath) + require.NoError(t, err) + assert.Equal(t, "synced stuff\n", string(shelltimeResult)) + }) + + t.Run("original file does not exist", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + directive := &IncludeDirective{ + OriginalPath: filepath.Join(tmpDir, "nonexistent"), + ShelltimePath: filepath.Join(tmpDir, "nonexistent.shelltime"), + IncludeLine: "source " + filepath.Join(tmpDir, "nonexistent.shelltime"), + CheckString: "nonexistent.shelltime", + } + + err = app.ensureIncludeSetup(directive) + require.NoError(t, err) // Should not error, just skip + }) + + t.Run("git config multi-line include", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + originalPath := filepath.Join(tmpDir, ".gitconfig") + shelltimePath := filepath.Join(tmpDir, ".gitconfig.shelltime") + originalContent := "[user]\n name = Test User\n email = test@example.com\n" + err = os.WriteFile(originalPath, []byte(originalContent), 0644) + require.NoError(t, err) + + directive := &IncludeDirective{ + OriginalPath: originalPath, + ShelltimePath: shelltimePath, + IncludeLine: "[include]\n path = " + shelltimePath, + CheckString: ".gitconfig.shelltime", + } + + err = app.ensureIncludeSetup(directive) + require.NoError(t, err) + + // .shelltime file should have original content + shelltimeContent, err := os.ReadFile(shelltimePath) + require.NoError(t, err) + assert.Equal(t, originalContent, string(shelltimeContent)) + + // Original should have multi-line include at top + updatedOriginal, err := os.ReadFile(originalPath) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(updatedOriginal), "[include]\n")) + assert.Contains(t, string(updatedOriginal), shelltimePath) + assert.Contains(t, string(updatedOriginal), "[user]") + }) +} + +func TestBaseApp_ensureIncludeLineInFile(t *testing.T) { + app := &BaseApp{name: "test"} + + t.Run("adds include line when missing", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + originalPath := filepath.Join(tmpDir, ".bashrc") + err = os.WriteFile(originalPath, []byte("alias ll='ls -la'\n"), 0644) + require.NoError(t, err) + + directive := &IncludeDirective{ + OriginalPath: originalPath, + ShelltimePath: filepath.Join(tmpDir, ".bashrc.shelltime"), + IncludeLine: "source ~/.bashrc.shelltime", + CheckString: ".bashrc.shelltime", + } + + err = app.ensureIncludeLineInFile(directive, false) + require.NoError(t, err) + + content, err := os.ReadFile(originalPath) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(content), "source ~/.bashrc.shelltime")) + assert.Contains(t, string(content), "alias ll='ls -la'") + }) + + t.Run("skips when include already exists", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + originalPath := filepath.Join(tmpDir, ".bashrc") + existingContent := "source ~/.bashrc.shelltime\nalias ll='ls -la'\n" + err = os.WriteFile(originalPath, []byte(existingContent), 0644) + require.NoError(t, err) + + directive := &IncludeDirective{ + OriginalPath: originalPath, + ShelltimePath: filepath.Join(tmpDir, ".bashrc.shelltime"), + IncludeLine: "source ~/.bashrc.shelltime", + CheckString: ".bashrc.shelltime", + } + + err = app.ensureIncludeLineInFile(directive, false) + require.NoError(t, err) + + content, err := os.ReadFile(originalPath) + require.NoError(t, err) + assert.Equal(t, existingContent, string(content)) // Unchanged + }) + + t.Run("creates file with include line when original does not exist", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + originalPath := filepath.Join(tmpDir, ".bashrc") + + directive := &IncludeDirective{ + OriginalPath: originalPath, + ShelltimePath: filepath.Join(tmpDir, ".bashrc.shelltime"), + IncludeLine: "source ~/.bashrc.shelltime", + CheckString: ".bashrc.shelltime", + } + + err = app.ensureIncludeLineInFile(directive, false) + require.NoError(t, err) + + content, err := os.ReadFile(originalPath) + require.NoError(t, err) + assert.Equal(t, "source ~/.bashrc.shelltime\n", string(content)) + }) + + t.Run("dry run does not modify file", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + originalPath := filepath.Join(tmpDir, ".bashrc") + originalContent := "alias ll='ls -la'\n" + err = os.WriteFile(originalPath, []byte(originalContent), 0644) + require.NoError(t, err) + + directive := &IncludeDirective{ + OriginalPath: originalPath, + ShelltimePath: filepath.Join(tmpDir, ".bashrc.shelltime"), + IncludeLine: "source ~/.bashrc.shelltime", + CheckString: ".bashrc.shelltime", + } + + err = app.ensureIncludeLineInFile(directive, true) + require.NoError(t, err) + + content, err := os.ReadFile(originalPath) + require.NoError(t, err) + assert.Equal(t, originalContent, string(content)) // Unchanged + }) +} + +func TestBaseApp_CollectWithIncludeSupport(t *testing.T) { + app := &BaseApp{name: "test"} + ctx := context.Background() + + t.Run("collects from shelltime file for includable path", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + originalPath := filepath.Join(tmpDir, ".bashrc") + shelltimePath := filepath.Join(tmpDir, ".bashrc.shelltime") + originalContent := "alias ll='ls -la'\nexport EDITOR=vim\n" + err = os.WriteFile(originalPath, []byte(originalContent), 0644) + require.NoError(t, err) + + directives := []IncludeDirective{ + { + OriginalPath: originalPath, + ShelltimePath: shelltimePath, + IncludeLine: "source " + shelltimePath, + CheckString: ".bashrc.shelltime", + }, + } + + skipIgnored := true + dotfiles, err := app.CollectWithIncludeSupport(ctx, "bash", []string{originalPath}, &skipIgnored, directives) + require.NoError(t, err) + require.Len(t, dotfiles, 1) + + // Should collect from .shelltime path + assert.Equal(t, shelltimePath, dotfiles[0].Path) + assert.Equal(t, originalContent, dotfiles[0].Content) + + // Original should now have include line + updatedOriginal, err := os.ReadFile(originalPath) + require.NoError(t, err) + assert.Contains(t, string(updatedOriginal), ".bashrc.shelltime") + }) + + t.Run("collects non-include paths normally", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + regularFile := filepath.Join(tmpDir, ".gitignore_global") + err = os.WriteFile(regularFile, []byte("*.swp\n.DS_Store\n"), 0644) + require.NoError(t, err) + + // No directives for this path + directives := []IncludeDirective{ + { + OriginalPath: filepath.Join(tmpDir, ".gitconfig"), + ShelltimePath: filepath.Join(tmpDir, ".gitconfig.shelltime"), + IncludeLine: "[include]\n path = " + filepath.Join(tmpDir, ".gitconfig.shelltime"), + CheckString: ".gitconfig.shelltime", + }, + } + + skipIgnored := true + dotfiles, err := app.CollectWithIncludeSupport(ctx, "git", []string{regularFile}, &skipIgnored, directives) + require.NoError(t, err) + require.Len(t, dotfiles, 1) + + // Should collect from the original path directly + assert.Equal(t, regularFile, dotfiles[0].Path) + assert.Equal(t, "*.swp\n.DS_Store\n", dotfiles[0].Content) + }) + + t.Run("handles mixed includable and non-includable paths", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + gitconfigPath := filepath.Join(tmpDir, ".gitconfig") + gitignorePath := filepath.Join(tmpDir, ".gitignore_global") + shelltimePath := filepath.Join(tmpDir, ".gitconfig.shelltime") + + err = os.WriteFile(gitconfigPath, []byte("[user]\n name = Test\n"), 0644) + require.NoError(t, err) + err = os.WriteFile(gitignorePath, []byte("*.swp\n"), 0644) + require.NoError(t, err) + + directives := []IncludeDirective{ + { + OriginalPath: gitconfigPath, + ShelltimePath: shelltimePath, + IncludeLine: "[include]\n path = " + shelltimePath, + CheckString: ".gitconfig.shelltime", + }, + } + + skipIgnored := true + dotfiles, err := app.CollectWithIncludeSupport(ctx, "git", []string{gitconfigPath, gitignorePath}, &skipIgnored, directives) + require.NoError(t, err) + require.Len(t, dotfiles, 2) + + // Find each dotfile by path + var shelltimeDotfile, gitignoreDotfile *DotfileItem + for i := range dotfiles { + if dotfiles[i].Path == shelltimePath { + shelltimeDotfile = &dotfiles[i] + } else if dotfiles[i].Path == gitignorePath { + gitignoreDotfile = &dotfiles[i] + } + } + + require.NotNil(t, shelltimeDotfile, ".shelltime file should be collected") + require.NotNil(t, gitignoreDotfile, ".gitignore_global should be collected directly") + }) + + t.Run("skips non-existent original file", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + nonExistent := filepath.Join(tmpDir, ".bashrc") + directives := []IncludeDirective{ + { + OriginalPath: nonExistent, + ShelltimePath: filepath.Join(tmpDir, ".bashrc.shelltime"), + IncludeLine: "source " + filepath.Join(tmpDir, ".bashrc.shelltime"), + CheckString: ".bashrc.shelltime", + }, + } + + skipIgnored := true + dotfiles, err := app.CollectWithIncludeSupport(ctx, "bash", []string{nonExistent}, &skipIgnored, directives) + require.NoError(t, err) + assert.Empty(t, dotfiles) + }) + + t.Run("handles directory paths without include", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create a directory with files + dirPath := filepath.Join(tmpDir, "conf.d") + err = os.MkdirAll(dirPath, 0755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(dirPath, "aliases.fish"), []byte("alias g='git'\n"), 0644) + require.NoError(t, err) + + directives := []IncludeDirective{ + { + OriginalPath: filepath.Join(tmpDir, "config.fish"), + ShelltimePath: filepath.Join(tmpDir, "config.fish.shelltime"), + IncludeLine: "source " + filepath.Join(tmpDir, "config.fish.shelltime"), + CheckString: "config.fish.shelltime", + }, + } + + skipIgnored := true + dotfiles, err := app.CollectWithIncludeSupport(ctx, "fish", []string{dirPath}, &skipIgnored, directives) + require.NoError(t, err) + require.Len(t, dotfiles, 1) + assert.Contains(t, dotfiles[0].Path, "aliases.fish") + }) +} + +func TestBaseApp_SaveWithIncludeSupport(t *testing.T) { + app := &BaseApp{name: "test"} + ctx := context.Background() + + t.Run("saves shelltime file and ensures include line", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + originalPath := filepath.Join(tmpDir, ".bashrc") + shelltimePath := filepath.Join(tmpDir, ".bashrc.shelltime") + + // Original file without include line + err = os.WriteFile(originalPath, []byte("existing stuff\n"), 0644) + require.NoError(t, err) + + directives := []IncludeDirective{ + { + OriginalPath: originalPath, + ShelltimePath: shelltimePath, + IncludeLine: "source " + shelltimePath, + CheckString: ".bashrc.shelltime", + }, + } + + files := map[string]string{ + shelltimePath: "synced content\n", + } + + err = app.SaveWithIncludeSupport(ctx, files, false, directives) + require.NoError(t, err) + + // .shelltime file should be written + shelltimeContent, err := os.ReadFile(shelltimePath) + require.NoError(t, err) + assert.Equal(t, "synced content\n", string(shelltimeContent)) + + // Original should now have include line + originalContent, err := os.ReadFile(originalPath) + require.NoError(t, err) + assert.Contains(t, string(originalContent), ".bashrc.shelltime") + }) + + t.Run("saves non-shelltime file normally", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + regularPath := filepath.Join(tmpDir, ".gitignore_global") + + directives := []IncludeDirective{ + { + OriginalPath: filepath.Join(tmpDir, ".gitconfig"), + ShelltimePath: filepath.Join(tmpDir, ".gitconfig.shelltime"), + IncludeLine: "[include]\n path = " + filepath.Join(tmpDir, ".gitconfig.shelltime"), + CheckString: ".gitconfig.shelltime", + }, + } + + files := map[string]string{ + regularPath: "*.swp\n.DS_Store\n", + } + + err = app.SaveWithIncludeSupport(ctx, files, false, directives) + require.NoError(t, err) + + content, err := os.ReadFile(regularPath) + require.NoError(t, err) + assert.Equal(t, "*.swp\n.DS_Store\n", string(content)) + }) + + t.Run("dry run does not modify original file", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dotfile-include-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + originalPath := filepath.Join(tmpDir, ".bashrc") + shelltimePath := filepath.Join(tmpDir, ".bashrc.shelltime") + originalContent := "existing stuff\n" + err = os.WriteFile(originalPath, []byte(originalContent), 0644) + require.NoError(t, err) + + directives := []IncludeDirective{ + { + OriginalPath: originalPath, + ShelltimePath: shelltimePath, + IncludeLine: "source " + shelltimePath, + CheckString: ".bashrc.shelltime", + }, + } + + files := map[string]string{ + shelltimePath: "synced content\n", + } + + err = app.SaveWithIncludeSupport(ctx, files, true, directives) + require.NoError(t, err) + + // Original should NOT have include line in dry run + content, err := os.ReadFile(originalPath) + require.NoError(t, err) + assert.Equal(t, originalContent, string(content)) + }) +} + +func TestGetIncludeDirectives(t *testing.T) { + t.Run("git app has directives for gitconfig files", func(t *testing.T) { + app := NewGitApp() + directives := app.GetIncludeDirectives() + assert.Len(t, directives, 2) + + // Check .gitconfig directive + assert.Equal(t, "~/.gitconfig", directives[0].OriginalPath) + assert.Equal(t, "~/.gitconfig.shelltime", directives[0].ShelltimePath) + assert.Contains(t, directives[0].IncludeLine, "[include]") + assert.Contains(t, directives[0].IncludeLine, ".gitconfig.shelltime") + + // Check config/git/config directive + assert.Equal(t, "~/.config/git/config", directives[1].OriginalPath) + assert.Equal(t, "~/.config/git/config.shelltime", directives[1].ShelltimePath) + }) + + t.Run("zsh app has directives for shell configs", func(t *testing.T) { + app := NewZshApp() + directives := app.GetIncludeDirectives() + assert.Len(t, directives, 3) + + for _, d := range directives { + assert.Contains(t, d.IncludeLine, "source") + assert.Contains(t, d.IncludeLine, ".shelltime") + } + }) + + t.Run("bash app has directives for all bash configs", func(t *testing.T) { + app := NewBashApp() + directives := app.GetIncludeDirectives() + assert.Len(t, directives, 4) + + expectedPaths := []string{"~/.bashrc", "~/.bash_profile", "~/.bash_aliases", "~/.bash_logout"} + for i, d := range directives { + assert.Equal(t, expectedPaths[i], d.OriginalPath) + assert.Contains(t, d.IncludeLine, "source") + } + }) + + t.Run("fish app has directive for config.fish", func(t *testing.T) { + app := NewFishApp() + directives := app.GetIncludeDirectives() + assert.Len(t, directives, 1) + assert.Equal(t, "~/.config/fish/config.fish", directives[0].OriginalPath) + assert.Contains(t, directives[0].IncludeLine, "source") + }) + + t.Run("ssh app has directive for ssh config", func(t *testing.T) { + app := NewSshApp() + directives := app.GetIncludeDirectives() + assert.Len(t, directives, 1) + assert.Equal(t, "~/.ssh/config", directives[0].OriginalPath) + assert.Contains(t, directives[0].IncludeLine, "Include") + }) + + t.Run("nvim app has directive for vimrc", func(t *testing.T) { + app := NewNvimApp() + directives := app.GetIncludeDirectives() + assert.Len(t, directives, 1) + assert.Equal(t, "~/.vimrc", directives[0].OriginalPath) + assert.Contains(t, directives[0].IncludeLine, "source") + }) + + t.Run("apps without include support return nil", func(t *testing.T) { + apps := []DotfileApp{ + NewGhosttyApp(), + NewClaudeApp(), + NewStarshipApp(), + NewNpmApp(), + NewKittyApp(), + NewKubernetesApp(), + } + + for _, app := range apps { + directives := app.GetIncludeDirectives() + assert.Nil(t, directives, "App %s should return nil directives", app.Name()) + } + }) +} + +func TestIncludeDirective_EndToEnd(t *testing.T) { + t.Run("push then pull workflow", func(t *testing.T) { + app := &BaseApp{name: "test"} + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "dotfile-e2e-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + originalPath := filepath.Join(tmpDir, ".gitconfig") + shelltimePath := filepath.Join(tmpDir, ".gitconfig.shelltime") + + originalContent := "[user]\n name = Test User\n email = test@example.com\n" + err = os.WriteFile(originalPath, []byte(originalContent), 0644) + require.NoError(t, err) + + directive := IncludeDirective{ + OriginalPath: originalPath, + ShelltimePath: shelltimePath, + IncludeLine: "[include]\n path = " + shelltimePath, + CheckString: ".gitconfig.shelltime", + } + directives := []IncludeDirective{directive} + + // Simulate PUSH: collect dotfiles + skipIgnored := true + dotfiles, err := app.CollectWithIncludeSupport(ctx, "git", []string{originalPath}, &skipIgnored, directives) + require.NoError(t, err) + require.Len(t, dotfiles, 1) + + // The collected dotfile should be from .shelltime path + assert.Equal(t, shelltimePath, dotfiles[0].Path) + assert.Equal(t, originalContent, dotfiles[0].Content) + + // Original should now have include line + updatedOriginal, err := os.ReadFile(originalPath) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(updatedOriginal), "[include]")) + + // Simulate server storing and returning updated content + serverContent := "[user]\n name = Updated User\n email = updated@example.com\n[core]\n editor = vim\n" + + // Simulate PULL: save to .shelltime file + files := map[string]string{ + shelltimePath: serverContent, + } + err = app.SaveWithIncludeSupport(ctx, files, false, directives) + require.NoError(t, err) + + // .shelltime file should have server content + shelltimeContent, err := os.ReadFile(shelltimePath) + require.NoError(t, err) + assert.Equal(t, serverContent, string(shelltimeContent)) + + // Original should still have include line + finalOriginal, err := os.ReadFile(originalPath) + require.NoError(t, err) + assert.Contains(t, string(finalOriginal), ".gitconfig.shelltime") + }) +} diff --git a/model/dotfile_nvim.go b/model/dotfile_nvim.go index 37feb68..2c3af4d 100644 --- a/model/dotfile_nvim.go +++ b/model/dotfile_nvim.go @@ -22,7 +22,22 @@ func (n *NvimApp) GetConfigPaths() []string { } } +func (n *NvimApp) GetIncludeDirectives() []IncludeDirective { + return []IncludeDirective{ + { + OriginalPath: "~/.vimrc", + ShelltimePath: "~/.vimrc.shelltime", + IncludeLine: "if filereadable(expand('~/.vimrc.shelltime')) | source ~/.vimrc.shelltime | endif", + CheckString: ".vimrc.shelltime", + }, + } +} + func (n *NvimApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) { skipIgnored := true - return n.CollectFromPaths(ctx, n.Name(), n.GetConfigPaths(), &skipIgnored) -} \ No newline at end of file + return n.CollectWithIncludeSupport(ctx, n.Name(), n.GetConfigPaths(), &skipIgnored, n.GetIncludeDirectives()) +} + +func (n *NvimApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error { + return n.SaveWithIncludeSupport(ctx, files, isDryRun, n.GetIncludeDirectives()) +} diff --git a/model/dotfile_ssh.go b/model/dotfile_ssh.go index d5b4758..b0345d3 100644 --- a/model/dotfile_ssh.go +++ b/model/dotfile_ssh.go @@ -21,7 +21,22 @@ func (s *SshApp) GetConfigPaths() []string { } } +func (s *SshApp) GetIncludeDirectives() []IncludeDirective { + return []IncludeDirective{ + { + OriginalPath: "~/.ssh/config", + ShelltimePath: "~/.ssh/config.shelltime", + IncludeLine: "Include ~/.ssh/config.shelltime", + CheckString: "config.shelltime", + }, + } +} + func (s *SshApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) { skipIgnored := true - return s.CollectFromPaths(ctx, s.Name(), s.GetConfigPaths(), &skipIgnored) + return s.CollectWithIncludeSupport(ctx, s.Name(), s.GetConfigPaths(), &skipIgnored, s.GetIncludeDirectives()) +} + +func (s *SshApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error { + return s.SaveWithIncludeSupport(ctx, files, isDryRun, s.GetIncludeDirectives()) } diff --git a/model/dotfile_zsh.go b/model/dotfile_zsh.go index 9c72114..80c191d 100644 --- a/model/dotfile_zsh.go +++ b/model/dotfile_zsh.go @@ -24,7 +24,34 @@ func (z *ZshApp) GetConfigPaths() []string { } } +func (z *ZshApp) GetIncludeDirectives() []IncludeDirective { + return []IncludeDirective{ + { + OriginalPath: "~/.zshrc", + ShelltimePath: "~/.zshrc.shelltime", + IncludeLine: "[[ -f ~/.zshrc.shelltime ]] && source ~/.zshrc.shelltime", + CheckString: ".zshrc.shelltime", + }, + { + OriginalPath: "~/.zshenv", + ShelltimePath: "~/.zshenv.shelltime", + IncludeLine: "[[ -f ~/.zshenv.shelltime ]] && source ~/.zshenv.shelltime", + CheckString: ".zshenv.shelltime", + }, + { + OriginalPath: "~/.zprofile", + ShelltimePath: "~/.zprofile.shelltime", + IncludeLine: "[[ -f ~/.zprofile.shelltime ]] && source ~/.zprofile.shelltime", + CheckString: ".zprofile.shelltime", + }, + } +} + func (z *ZshApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) { skipIgnored := true - return z.CollectFromPaths(ctx, z.Name(), z.GetConfigPaths(), &skipIgnored) -} \ No newline at end of file + return z.CollectWithIncludeSupport(ctx, z.Name(), z.GetConfigPaths(), &skipIgnored, z.GetIncludeDirectives()) +} + +func (z *ZshApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error { + return z.SaveWithIncludeSupport(ctx, files, isDryRun, z.GetIncludeDirectives()) +}