Skip to content

Commit eaf7ea2

Browse files
authored
Merge pull request #104 from shelltime/feat/issue-101-dotfile-merge
feat(dotfile): add advanced merge support for config files
2 parents a8332ee + f32fd42 commit eaf7ea2

1 file changed

Lines changed: 189 additions & 3 deletions

File tree

model/dotfile_ghostty.go

Lines changed: 189 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
package model
22

3-
import "context"
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/sirupsen/logrus"
12+
)
413

514
// GhosttyApp handles Ghostty terminal configuration files
615
type GhosttyApp struct {
@@ -18,11 +27,188 @@ func (g *GhosttyApp) Name() string {
1827
func (g *GhosttyApp) GetConfigPaths() []string {
1928
return []string{
2029
"~/.config/ghostty/config",
21-
"~/.config/ghostty",
2230
}
2331
}
2432

2533
func (g *GhosttyApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) {
2634
skipIgnored := true
2735
return g.CollectFromPaths(ctx, g.Name(), g.GetConfigPaths(), &skipIgnored)
28-
}
36+
}
37+
38+
// configLine represents a line in the Ghostty config file
39+
type configLine struct {
40+
isComment bool
41+
isBlank bool
42+
key string
43+
value string
44+
raw string // for comments and blank lines
45+
}
46+
47+
// parseGhosttyConfig parses Ghostty config content into structured lines
48+
func (g *GhosttyApp) parseGhosttyConfig(content string) []configLine {
49+
var lines []configLine
50+
scanner := bufio.NewScanner(strings.NewReader(content))
51+
52+
for scanner.Scan() {
53+
line := scanner.Text()
54+
trimmed := strings.TrimSpace(line)
55+
56+
if trimmed == "" {
57+
lines = append(lines, configLine{
58+
isBlank: true,
59+
raw: line,
60+
})
61+
} else if strings.HasPrefix(trimmed, "#") {
62+
lines = append(lines, configLine{
63+
isComment: true,
64+
raw: line,
65+
})
66+
} else if strings.Contains(line, "=") {
67+
parts := strings.SplitN(line, "=", 2)
68+
key := strings.TrimSpace(parts[0])
69+
value := ""
70+
if len(parts) > 1 {
71+
value = strings.TrimSpace(parts[1])
72+
}
73+
lines = append(lines, configLine{
74+
key: key,
75+
value: value,
76+
raw: line,
77+
})
78+
} else {
79+
// Treat as a comment if it doesn't match key=value format
80+
lines = append(lines, configLine{
81+
isComment: true,
82+
raw: line,
83+
})
84+
}
85+
}
86+
87+
return lines
88+
}
89+
90+
// mergeGhosttyConfigs merges remote config with local config, local has priority
91+
func (g *GhosttyApp) mergeGhosttyConfigs(localLines, remoteLines []configLine) []configLine {
92+
// Create a map of local keys for quick lookup
93+
localKeys := make(map[string]bool)
94+
for _, line := range localLines {
95+
if !line.isComment && !line.isBlank && line.key != "" {
96+
localKeys[line.key] = true
97+
}
98+
}
99+
100+
// Start with local config
101+
merged := make([]configLine, len(localLines))
102+
copy(merged, localLines)
103+
104+
// Add keys from remote that don't exist in local
105+
for _, remoteLine := range remoteLines {
106+
if !remoteLine.isComment && !remoteLine.isBlank && remoteLine.key != "" {
107+
if !localKeys[remoteLine.key] {
108+
merged = append(merged, remoteLine)
109+
}
110+
}
111+
}
112+
113+
return merged
114+
}
115+
116+
// formatGhosttyConfig converts config lines back to string
117+
func (g *GhosttyApp) formatGhosttyConfig(lines []configLine) string {
118+
var result []string
119+
for _, line := range lines {
120+
if line.isComment || line.isBlank {
121+
result = append(result, line.raw)
122+
} else {
123+
result = append(result, fmt.Sprintf("%s = %s", line.key, line.value))
124+
}
125+
}
126+
return strings.Join(result, "\n")
127+
}
128+
129+
// Save overrides the base Save method to handle Ghostty's specific config format
130+
func (g *GhosttyApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error {
131+
for path, remoteContent := range files {
132+
expandedPath, err := g.expandPath(path)
133+
if err != nil {
134+
logrus.Warnf("Failed to expand path %s: %v", path, err)
135+
continue
136+
}
137+
138+
// Read existing local content if file exists
139+
var localContent string
140+
if existingBytes, err := os.ReadFile(expandedPath); err == nil {
141+
localContent = string(existingBytes)
142+
} else if !os.IsNotExist(err) {
143+
logrus.Warnf("Failed to read existing file %s: %v", expandedPath, err)
144+
continue
145+
}
146+
147+
// Parse both configs
148+
localLines := g.parseGhosttyConfig(localContent)
149+
remoteLines := g.parseGhosttyConfig(remoteContent)
150+
151+
// Merge configs (local has priority)
152+
mergedLines := g.mergeGhosttyConfigs(localLines, remoteLines)
153+
mergedContent := g.formatGhosttyConfig(mergedLines)
154+
155+
// Check if there are any differences
156+
if localContent == mergedContent {
157+
logrus.Infof("No changes needed for %s", expandedPath)
158+
continue
159+
}
160+
161+
if isDryRun {
162+
// In dry-run mode, show the diff
163+
fmt.Printf("\n📄 %s:\n", expandedPath)
164+
fmt.Println("--- Changes to be applied ---")
165+
166+
// Show added keys (from remote)
167+
remoteKeys := make(map[string]string)
168+
for _, line := range remoteLines {
169+
if !line.isComment && !line.isBlank && line.key != "" {
170+
remoteKeys[line.key] = line.value
171+
}
172+
}
173+
174+
localKeys := make(map[string]string)
175+
for _, line := range localLines {
176+
if !line.isComment && !line.isBlank && line.key != "" {
177+
localKeys[line.key] = line.value
178+
}
179+
}
180+
181+
// Show new keys from remote
182+
hasChanges := false
183+
for key, value := range remoteKeys {
184+
if _, exists := localKeys[key]; !exists {
185+
fmt.Printf("+ %s = %s (from remote)\n", key, value)
186+
hasChanges = true
187+
}
188+
}
189+
190+
if !hasChanges {
191+
fmt.Println("No new keys from remote")
192+
}
193+
194+
fmt.Println("--- End of changes ---")
195+
continue
196+
}
197+
198+
// Ensure directory exists
199+
dir := filepath.Dir(expandedPath)
200+
if err := os.MkdirAll(dir, 0755); err != nil {
201+
logrus.Warnf("Failed to create directory %s: %v", dir, err)
202+
continue
203+
}
204+
205+
// Write merged content
206+
if err := os.WriteFile(expandedPath, []byte(mergedContent), 0644); err != nil {
207+
logrus.Warnf("Failed to save file %s: %v", expandedPath, err)
208+
} else {
209+
logrus.Infof("Saved merged config to %s", expandedPath)
210+
}
211+
}
212+
213+
return nil
214+
}

0 commit comments

Comments
 (0)