Skip to content

Commit e60fd06

Browse files
committed
refactor(model): add include directive support for dotfile management
Instead of directly syncing config files, configs that support include directives now use a two-file approach: - The original file (e.g., ~/.gitconfig) gets an include line added at top - The actual synced content lives in a .shelltime suffixed file (e.g., ~/.gitconfig.shelltime) On push: ensures include line exists in original, copies content to .shelltime file, and uploads the .shelltime version to server. On pull: ensures include line exists in original, downloads content from server to the .shelltime file. Supported include syntaxes by app: - git: [include] path = ~/.gitconfig.shelltime - zsh/bash: [[ -f ~/.zshrc.shelltime ]] && source ~/.zshrc.shelltime - fish: test -f <path>.shelltime; and source <path>.shelltime - ssh: Include ~/.ssh/config.shelltime - nvim: if filereadable(expand('~/.vimrc.shelltime')) | source ... | endif Apps without include support (ghostty, claude, starship, npm, kitty, kubernetes) keep the existing direct push/pull behavior. Directory-based configs also keep existing behavior. https://claude.ai/code/session_01WKUeEgn1nMULetY8i2WwDR
1 parent 6329e88 commit e60fd06

9 files changed

Lines changed: 1139 additions & 11 deletions

model/dotfile_apps.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ func GetAllAppsMap() map[DotfileAppName]DotfileApp {
6767
type DotfileApp interface {
6868
Name() string
6969
GetConfigPaths() []string
70+
GetIncludeDirectives() []IncludeDirective
7071
CollectDotfiles(ctx context.Context) ([]DotfileItem, error)
7172
IsEqual(ctx context.Context, files map[string]string) (map[string]bool, error)
7273
Backup(ctx context.Context, paths []string, isDryRun bool) error
@@ -78,6 +79,12 @@ type BaseApp struct {
7879
name string
7980
}
8081

82+
// GetIncludeDirectives returns an empty slice by default.
83+
// Apps that support include directives should override this method.
84+
func (b *BaseApp) GetIncludeDirectives() []IncludeDirective {
85+
return nil
86+
}
87+
8188
func (b *BaseApp) expandPath(path string) (string, error) {
8289
if strings.HasPrefix(path, "~") {
8390
homeDir, err := os.UserHomeDir()

model/dotfile_bash.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,40 @@ func (b *BashApp) GetConfigPaths() []string {
2424
}
2525
}
2626

27+
func (b *BashApp) GetIncludeDirectives() []IncludeDirective {
28+
return []IncludeDirective{
29+
{
30+
OriginalPath: "~/.bashrc",
31+
ShelltimePath: "~/.bashrc.shelltime",
32+
IncludeLine: "[[ -f ~/.bashrc.shelltime ]] && source ~/.bashrc.shelltime",
33+
CheckString: ".bashrc.shelltime",
34+
},
35+
{
36+
OriginalPath: "~/.bash_profile",
37+
ShelltimePath: "~/.bash_profile.shelltime",
38+
IncludeLine: "[[ -f ~/.bash_profile.shelltime ]] && source ~/.bash_profile.shelltime",
39+
CheckString: ".bash_profile.shelltime",
40+
},
41+
{
42+
OriginalPath: "~/.bash_aliases",
43+
ShelltimePath: "~/.bash_aliases.shelltime",
44+
IncludeLine: "[[ -f ~/.bash_aliases.shelltime ]] && source ~/.bash_aliases.shelltime",
45+
CheckString: ".bash_aliases.shelltime",
46+
},
47+
{
48+
OriginalPath: "~/.bash_logout",
49+
ShelltimePath: "~/.bash_logout.shelltime",
50+
IncludeLine: "[[ -f ~/.bash_logout.shelltime ]] && source ~/.bash_logout.shelltime",
51+
CheckString: ".bash_logout.shelltime",
52+
},
53+
}
54+
}
55+
2756
func (b *BashApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) {
2857
skipIgnored := true
29-
return b.CollectFromPaths(ctx, b.Name(), b.GetConfigPaths(), &skipIgnored)
30-
}
58+
return b.CollectWithIncludeSupport(ctx, b.Name(), b.GetConfigPaths(), &skipIgnored, b.GetIncludeDirectives())
59+
}
60+
61+
func (b *BashApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error {
62+
return b.SaveWithIncludeSupport(ctx, files, isDryRun, b.GetIncludeDirectives())
63+
}

model/dotfile_fish.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,22 @@ func (f *FishApp) GetConfigPaths() []string {
2323
}
2424
}
2525

26+
func (f *FishApp) GetIncludeDirectives() []IncludeDirective {
27+
return []IncludeDirective{
28+
{
29+
OriginalPath: "~/.config/fish/config.fish",
30+
ShelltimePath: "~/.config/fish/config.fish.shelltime",
31+
IncludeLine: "test -f ~/.config/fish/config.fish.shelltime; and source ~/.config/fish/config.fish.shelltime",
32+
CheckString: "config.fish.shelltime",
33+
},
34+
}
35+
}
36+
2637
func (f *FishApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) {
2738
skipIgnored := true
28-
return f.CollectFromPaths(ctx, f.Name(), f.GetConfigPaths(), &skipIgnored)
29-
}
39+
return f.CollectWithIncludeSupport(ctx, f.Name(), f.GetConfigPaths(), &skipIgnored, f.GetIncludeDirectives())
40+
}
41+
42+
func (f *FishApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error {
43+
return f.SaveWithIncludeSupport(ctx, files, isDryRun, f.GetIncludeDirectives())
44+
}

model/dotfile_git.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,28 @@ func (g *GitApp) GetConfigPaths() []string {
2424
}
2525
}
2626

27+
func (g *GitApp) GetIncludeDirectives() []IncludeDirective {
28+
return []IncludeDirective{
29+
{
30+
OriginalPath: "~/.gitconfig",
31+
ShelltimePath: "~/.gitconfig.shelltime",
32+
IncludeLine: "[include]\n path = ~/.gitconfig.shelltime",
33+
CheckString: ".gitconfig.shelltime",
34+
},
35+
{
36+
OriginalPath: "~/.config/git/config",
37+
ShelltimePath: "~/.config/git/config.shelltime",
38+
IncludeLine: "[include]\n path = ~/.config/git/config.shelltime",
39+
CheckString: "git/config.shelltime",
40+
},
41+
}
42+
}
43+
2744
func (g *GitApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) {
2845
skipIgnored := true
29-
return g.CollectFromPaths(ctx, g.Name(), g.GetConfigPaths(), &skipIgnored)
30-
}
46+
return g.CollectWithIncludeSupport(ctx, g.Name(), g.GetConfigPaths(), &skipIgnored, g.GetIncludeDirectives())
47+
}
48+
49+
func (g *GitApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error {
50+
return g.SaveWithIncludeSupport(ctx, files, isDryRun, g.GetIncludeDirectives())
51+
}

model/dotfile_include.go

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
package model
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
)
10+
11+
// IncludeDirective defines how a config file includes its shelltime-managed version.
12+
// When pushing/pulling dotfiles, the original config file gets an include line added
13+
// at the top that sources the .shelltime version. The actual synced content lives
14+
// in the .shelltime file.
15+
type IncludeDirective struct {
16+
OriginalPath string // tilde path to original config, e.g., "~/.gitconfig"
17+
ShelltimePath string // tilde path to shelltime file, e.g., "~/.gitconfig.shelltime"
18+
IncludeLine string // The include line(s) to add at the top of the original file
19+
CheckString string // Substring to check if include already exists in file content
20+
}
21+
22+
// ensureIncludeSetup ensures the include directive is properly set up for push.
23+
// - If the original file has no include line and no .shelltime file exists:
24+
//
25+
// copies content to .shelltime and adds include line to original.
26+
//
27+
// - If the include line is missing: adds it to the original.
28+
// - If .shelltime file is missing but include line exists: extracts content from original.
29+
func (b *BaseApp) ensureIncludeSetup(directive *IncludeDirective) error {
30+
expandedOriginal, err := b.expandPath(directive.OriginalPath)
31+
if err != nil {
32+
return err
33+
}
34+
expandedShelltime, err := b.expandPath(directive.ShelltimePath)
35+
if err != nil {
36+
return err
37+
}
38+
39+
// Check if original file exists
40+
originalBytes, err := os.ReadFile(expandedOriginal)
41+
if err != nil {
42+
if os.IsNotExist(err) {
43+
return nil // Original doesn't exist, nothing to do
44+
}
45+
return err
46+
}
47+
48+
content := string(originalBytes)
49+
hasInclude := strings.Contains(content, directive.CheckString)
50+
_, shelltimeErr := os.Stat(expandedShelltime)
51+
shelltimeExists := shelltimeErr == nil
52+
53+
if hasInclude && shelltimeExists {
54+
return nil // Already set up
55+
}
56+
57+
if !hasInclude && !shelltimeExists {
58+
// First time setup: copy content to .shelltime, add include to original
59+
if err := writeFileWithDir(expandedShelltime, content); err != nil {
60+
return err
61+
}
62+
newOriginal := directive.IncludeLine + "\n" + content
63+
return os.WriteFile(expandedOriginal, []byte(newOriginal), 0644)
64+
}
65+
66+
if !hasInclude {
67+
// .shelltime exists but include line missing from original - add it
68+
newOriginal := directive.IncludeLine + "\n" + content
69+
return os.WriteFile(expandedOriginal, []byte(newOriginal), 0644)
70+
}
71+
72+
// hasInclude && !shelltimeExists
73+
// Include line exists but .shelltime file was deleted - extract content
74+
contentWithoutInclude := removeIncludeLines(content, directive)
75+
return writeFileWithDir(expandedShelltime, contentWithoutInclude)
76+
}
77+
78+
// ensureIncludeLineInFile ensures the include line exists in the original config file.
79+
// Used during pull to set up the include before saving the .shelltime file.
80+
func (b *BaseApp) ensureIncludeLineInFile(directive *IncludeDirective, isDryRun bool) error {
81+
expandedOriginal, err := b.expandPath(directive.OriginalPath)
82+
if err != nil {
83+
return err
84+
}
85+
86+
// Read original file (or treat as empty if it doesn't exist)
87+
var content string
88+
if data, err := os.ReadFile(expandedOriginal); err == nil {
89+
content = string(data)
90+
} else if !os.IsNotExist(err) {
91+
return err
92+
}
93+
94+
// Check if include already exists
95+
if strings.Contains(content, directive.CheckString) {
96+
return nil
97+
}
98+
99+
if isDryRun {
100+
slog.Info("[DRY RUN] Would add include line", slog.String("file", expandedOriginal))
101+
return nil
102+
}
103+
104+
// Add include line at top
105+
var newContent string
106+
if content == "" {
107+
newContent = directive.IncludeLine + "\n"
108+
} else {
109+
newContent = directive.IncludeLine + "\n" + content
110+
}
111+
112+
return writeFileWithDir(expandedOriginal, newContent)
113+
}
114+
115+
// CollectWithIncludeSupport collects dotfiles, handling include directives.
116+
// For paths with include directives, it ensures the include setup and collects from .shelltime files.
117+
// For other paths (directories, non-includable files), it uses standard collection.
118+
func (b *BaseApp) CollectWithIncludeSupport(ctx context.Context, appName string, paths []string, skipIgnored *bool, directives []IncludeDirective) ([]DotfileItem, error) {
119+
// Build directive lookup by expanded original path
120+
directiveMap := make(map[string]*IncludeDirective)
121+
for i, d := range directives {
122+
expanded, err := b.expandPath(d.OriginalPath)
123+
if err == nil {
124+
directiveMap[expanded] = &directives[i]
125+
}
126+
}
127+
128+
var allDotfiles []DotfileItem
129+
var nonIncludePaths []string
130+
131+
for _, path := range paths {
132+
expanded, err := b.expandPath(path)
133+
if err != nil {
134+
nonIncludePaths = append(nonIncludePaths, path)
135+
continue
136+
}
137+
138+
directive, found := directiveMap[expanded]
139+
if !found {
140+
nonIncludePaths = append(nonIncludePaths, path)
141+
continue
142+
}
143+
144+
// This path has include support
145+
// Check if original file exists
146+
if _, err := os.Stat(expanded); err != nil {
147+
slog.Debug("Original file not found, skipping include setup", slog.String("path", path))
148+
continue
149+
}
150+
151+
// Ensure include setup (adds include line, creates .shelltime file if needed)
152+
if err := b.ensureIncludeSetup(directive); err != nil {
153+
slog.Warn("Failed to ensure include setup", slog.String("path", path), slog.Any("err", err))
154+
continue
155+
}
156+
157+
// Collect from .shelltime file instead of original
158+
items, err := b.CollectFromPaths(ctx, appName, []string{directive.ShelltimePath}, skipIgnored)
159+
if err != nil {
160+
slog.Warn("Failed to collect from shelltime file", slog.String("path", directive.ShelltimePath), slog.Any("err", err))
161+
continue
162+
}
163+
allDotfiles = append(allDotfiles, items...)
164+
}
165+
166+
// Collect non-include paths normally (directories, non-includable files)
167+
if len(nonIncludePaths) > 0 {
168+
items, err := b.CollectFromPaths(ctx, appName, nonIncludePaths, skipIgnored)
169+
if err != nil {
170+
return allDotfiles, err
171+
}
172+
allDotfiles = append(allDotfiles, items...)
173+
}
174+
175+
return allDotfiles, nil
176+
}
177+
178+
// SaveWithIncludeSupport saves files, ensuring include directives for .shelltime files.
179+
// For .shelltime paths that match a known directive, it also ensures the include line
180+
// exists in the original config file.
181+
func (b *BaseApp) SaveWithIncludeSupport(ctx context.Context, files map[string]string, isDryRun bool, directives []IncludeDirective) error {
182+
// Build directive lookup by shelltime path (both tilde and expanded)
183+
shelltimeMap := make(map[string]*IncludeDirective)
184+
for i, d := range directives {
185+
shelltimeMap[d.ShelltimePath] = &directives[i]
186+
expanded, err := b.expandPath(d.ShelltimePath)
187+
if err == nil {
188+
shelltimeMap[expanded] = &directives[i]
189+
}
190+
}
191+
192+
// For .shelltime files, ensure include line in the original config
193+
for filePath := range files {
194+
if directive, found := shelltimeMap[filePath]; found {
195+
if err := b.ensureIncludeLineInFile(directive, isDryRun); err != nil {
196+
slog.Warn("Failed to ensure include line", slog.String("path", filePath), slog.Any("err", err))
197+
}
198+
}
199+
}
200+
201+
// Use base Save for actual file writing
202+
return b.Save(ctx, files, isDryRun)
203+
}
204+
205+
// writeFileWithDir writes content to a file, creating parent directories if needed.
206+
func writeFileWithDir(path, content string) error {
207+
dir := filepath.Dir(path)
208+
if err := os.MkdirAll(dir, 0755); err != nil {
209+
return err
210+
}
211+
return os.WriteFile(path, []byte(content), 0644)
212+
}
213+
214+
// removeIncludeLines removes the include directive lines from content.
215+
// First tries to match and remove from the top of the file.
216+
// Falls back to removing any lines containing the check string.
217+
func removeIncludeLines(content string, directive *IncludeDirective) string {
218+
lines := strings.Split(content, "\n")
219+
includeLines := strings.Split(directive.IncludeLine, "\n")
220+
221+
// Try to find and remove the include lines from the top
222+
if len(lines) >= len(includeLines) {
223+
allMatch := true
224+
for i, il := range includeLines {
225+
if strings.TrimSpace(lines[i]) != strings.TrimSpace(il) {
226+
allMatch = false
227+
break
228+
}
229+
}
230+
if allMatch {
231+
remaining := lines[len(includeLines):]
232+
// Remove leading empty line if present (we add \n after include line)
233+
if len(remaining) > 0 && strings.TrimSpace(remaining[0]) == "" {
234+
remaining = remaining[1:]
235+
}
236+
return strings.Join(remaining, "\n")
237+
}
238+
}
239+
240+
// Fallback: remove any line containing the check string
241+
var filtered []string
242+
for _, line := range lines {
243+
if !strings.Contains(line, directive.CheckString) {
244+
filtered = append(filtered, line)
245+
}
246+
}
247+
return strings.Join(filtered, "\n")
248+
}

0 commit comments

Comments
 (0)