Skip to content

Commit d176c01

Browse files
committed
feat: support multiple destinations per source in link config
- Add LinkMap custom type with YAML unmarshaling for backward compatibility - Support both old format (single string) and new format (array of strings) - Update link manager to handle multiple destinations per source - Update state tracking for multiple destinations - Update all tests to use new LinkMap type - Add documentation for multiple link destinations Example usage: link: # Old format still works "source": "dest" # New format for multiple destinations "source": ["dest1", "dest2", "dest3"]
1 parent 7bd61ac commit d176c01

File tree

8 files changed

+137
-45
lines changed

8 files changed

+137
-45
lines changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,27 @@ config:
177177
apt: "apt install -y tool"
178178
```
179179

180+
### Multiple Link Destinations
181+
182+
Link the same source file to multiple destinations (useful for monorepos or shared configs):
183+
184+
```yaml
185+
config:
186+
shared-configs:
187+
os: ["mac"]
188+
link:
189+
# Old format still works (single destination)
190+
"./config/shared/settings.json": "~/.config/settings.json"
191+
192+
# New format for multiple destinations
193+
"./config/shared/.eslintrc":
194+
- "~/project-a/.eslintrc"
195+
- "~/project-b/.eslintrc"
196+
- "~/project-c/.eslintrc"
197+
```
198+
199+
Both formats can be mixed in the same component. The old format (`source: dest`) is automatically converted to a single-item array for backward compatibility.
200+
180201
### Advanced Features
181202

182203
#### Nested Configs

internal/component/component_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,19 @@ func createTestComponentManager(t *testing.T, dryRun bool) (*Manager, string) {
3434

3535
rawConfig := map[string]config.Component{
3636
"bash": {
37-
Link: map[string]string{
38-
"bash/.bashrc": filepath.Join(homeDir, ".bashrc"),
37+
Link: config.LinkMap{
38+
"bash/.bashrc": []string{filepath.Join(homeDir, ".bashrc")},
3939
},
4040
},
4141
"git": {
42-
Link: map[string]string{
43-
"bash/.bashrc": filepath.Join(homeDir, ".gitconfig"),
42+
Link: config.LinkMap{
43+
"bash/.bashrc": []string{filepath.Join(homeDir, ".gitconfig")},
4444
},
4545
PostLink: "echo 'git post-link'",
4646
},
4747
"docker": {
48-
Link: map[string]string{
49-
"bash/.bashrc": filepath.Join(homeDir, ".dockerrc"),
48+
Link: config.LinkMap{
49+
"bash/.bashrc": []string{filepath.Join(homeDir, ".dockerrc")},
5050
},
5151
PostLink: "echo 'docker post-link'",
5252
},
@@ -83,7 +83,7 @@ func createTestComponentManager(t *testing.T, dryRun bool) (*Manager, string) {
8383
func TestNewManager(t *testing.T) {
8484
rawConfig := map[string]config.Component{
8585
"test": {
86-
Link: map[string]string{"test": "~/.test"},
86+
Link: config.LinkMap{"test": []string{"~/.test"}},
8787
},
8888
}
8989

internal/config/config.go

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,60 @@ type Config struct {
1717

1818
type ComponentMap map[string]Component
1919

20+
// LinkMap is a custom type that supports both single string and array of strings as values
21+
// This allows backward compatibility with the old format while supporting multiple destinations per source
22+
type LinkMap map[string][]string
23+
24+
// UnmarshalYAML implements custom YAML unmarshaling for LinkMap
25+
// Supports both formats:
26+
//
27+
// Old (single): "source": "dest"
28+
// New (multiple): "source": ["dest1", "dest2"]
29+
func (lm *LinkMap) UnmarshalYAML(node *yaml.Node) error {
30+
if node.Kind != yaml.MappingNode {
31+
return fmt.Errorf("link must be a mapping, got %s", node.Tag)
32+
}
33+
34+
*lm = make(LinkMap)
35+
36+
for i := 0; i < len(node.Content); i += 2 {
37+
keyNode := node.Content[i]
38+
valueNode := node.Content[i+1]
39+
40+
var key string
41+
if err := keyNode.Decode(&key); err != nil {
42+
return fmt.Errorf("failed to decode link key: %w", err)
43+
}
44+
45+
var destinations []string
46+
47+
switch valueNode.Kind {
48+
case yaml.ScalarNode:
49+
// Old format: single string value
50+
var dest string
51+
if err := valueNode.Decode(&dest); err != nil {
52+
return fmt.Errorf("failed to decode link destination for %s: %w", key, err)
53+
}
54+
destinations = []string{dest}
55+
case yaml.SequenceNode:
56+
// New format: array of destinations
57+
if err := valueNode.Decode(&destinations); err != nil {
58+
return fmt.Errorf("failed to decode link destinations for %s: %w", key, err)
59+
}
60+
default:
61+
return fmt.Errorf("link value for %s must be a string or array, got %s", key, valueNode.Tag)
62+
}
63+
64+
(*lm)[key] = destinations
65+
}
66+
67+
return nil
68+
}
69+
2070
type Component struct {
2171
Install map[string]string `yaml:"install,omitempty"`
2272
Uninstall map[string]string `yaml:"uninstall,omitempty"`
23-
Link map[string]string `yaml:"link,omitempty"`
73+
Link LinkMap `yaml:"link,omitempty"`
2474
PostInstall string `yaml:"postinstall,omitempty"`
2575
PostLink string `yaml:"postlink,omitempty"`
2676
OS []string `yaml:"os,omitempty"`

internal/link/link.go

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"os"
66
"path/filepath"
77
"strings"
8+
9+
"github.com/pablopunk/dot/internal/config"
810
)
911

1012
type Manager struct {
@@ -28,23 +30,27 @@ type LinkResult struct {
2830
Error error
2931
}
3032

31-
func (m *Manager) CreateLinks(linkMap map[string]string) ([]LinkResult, error) {
33+
func (m *Manager) CreateLinks(linkMap config.LinkMap) ([]LinkResult, error) {
3234
var results []LinkResult
3335

34-
for source, target := range linkMap {
35-
result := m.createLink(source, target)
36-
results = append(results, result)
36+
for source, targets := range linkMap {
37+
for _, target := range targets {
38+
result := m.createLink(source, target)
39+
results = append(results, result)
40+
}
3741
}
3842

3943
return results, nil
4044
}
4145

4246
// NeedsLinking checks if any of the links in linkMap need to be created or updated
4347
// Returns true if at least one link needs work, false if all links already exist correctly
44-
func (m *Manager) NeedsLinking(linkMap map[string]string) bool {
45-
for source, target := range linkMap {
46-
if m.needsLinking(source, target) {
47-
return true
48+
func (m *Manager) NeedsLinking(linkMap config.LinkMap) bool {
49+
for source, targets := range linkMap {
50+
for _, target := range targets {
51+
if m.needsLinking(source, target) {
52+
return true
53+
}
4854
}
4955
}
5056
return false
@@ -187,12 +193,14 @@ func (m *Manager) createLink(source, target string) LinkResult {
187193
}
188194
}
189195

190-
func (m *Manager) RemoveLinks(linkMap map[string]string) ([]LinkResult, error) {
196+
func (m *Manager) RemoveLinks(linkMap config.LinkMap) ([]LinkResult, error) {
191197
var results []LinkResult
192198

193-
for source, target := range linkMap {
194-
result := m.removeLink(source, target)
195-
results = append(results, result)
199+
for source, targets := range linkMap {
200+
for _, target := range targets {
201+
result := m.removeLink(source, target)
202+
results = append(results, result)
203+
}
196204
}
197205

198206
return results, nil

internal/link/link_test.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"os"
55
"path/filepath"
66
"testing"
7+
8+
"github.com/pablopunk/dot/internal/config"
79
)
810

911
func TestNewManager(t *testing.T) {
@@ -46,8 +48,8 @@ func TestCreateLinks(t *testing.T) {
4648

4749
manager := NewManager(baseDir, false, false)
4850

49-
linkMap := map[string]string{
50-
"bash/.bashrc": filepath.Join(homeDir, ".bashrc"),
51+
linkMap := config.LinkMap{
52+
"bash/.bashrc": []string{filepath.Join(homeDir, ".bashrc")},
5153
}
5254

5355
results, err := manager.CreateLinks(linkMap)
@@ -118,8 +120,8 @@ func TestCreateLinksExistingCorrectLink(t *testing.T) {
118120

119121
manager := NewManager(baseDir, false, false)
120122

121-
linkMap := map[string]string{
122-
"bash/.bashrc": targetFile,
123+
linkMap := config.LinkMap{
124+
"bash/.bashrc": []string{targetFile},
123125
}
124126

125127
results, err := manager.CreateLinks(linkMap)
@@ -159,8 +161,8 @@ func TestCreateLinksDryRun(t *testing.T) {
159161

160162
manager := NewManager(baseDir, true, false) // dry run enabled
161163

162-
linkMap := map[string]string{
163-
"bash/.bashrc": filepath.Join(homeDir, ".bashrc"),
164+
linkMap := config.LinkMap{
165+
"bash/.bashrc": []string{filepath.Join(homeDir, ".bashrc")},
164166
}
165167

166168
results, err := manager.CreateLinks(linkMap)
@@ -211,8 +213,8 @@ func TestRemoveLinks(t *testing.T) {
211213

212214
manager := NewManager(baseDir, false, false)
213215

214-
linkMap := map[string]string{
215-
"bash/.bashrc": targetFile,
216+
linkMap := config.LinkMap{
217+
"bash/.bashrc": []string{targetFile},
216218
}
217219

218220
results, err := manager.RemoveLinks(linkMap)

internal/profile/profile_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import (
99
func createTestConfig() *config.Config {
1010
rawConfig := map[string]config.Component{
1111
"bash": {
12-
Link: map[string]string{
13-
"bash/.bashrc": "~/.bashrc",
12+
Link: config.LinkMap{
13+
"bash/.bashrc": []string{"~/.bashrc"},
1414
},
1515
},
1616
"git": {
@@ -145,9 +145,9 @@ func TestGetActiveComponents(t *testing.T) {
145145

146146
func TestGetActiveComponents_MultipleProfiles(t *testing.T) {
147147
rawConfig := map[string]config.Component{
148-
"common": {Link: map[string]string{"s": "t"}},
149-
"c1": {Link: map[string]string{"s": "t"}},
150-
"c2": {Link: map[string]string{"s": "t"}},
148+
"common": {Link: config.LinkMap{"s": []string{"t"}}},
149+
"c1": {Link: config.LinkMap{"s": []string{"t"}}},
150+
"c2": {Link: config.LinkMap{"s": []string{"t"}}},
151151
}
152152

153153
configMap := make(map[string]interface{})

internal/state/state.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"gopkg.in/yaml.v3"
1010

11+
"github.com/pablopunk/dot/internal/config"
1112
"github.com/pablopunk/dot/internal/profile"
1213
)
1314

@@ -25,7 +26,7 @@ type ComponentState struct {
2526
PackageManager string `yaml:"package_manager,omitempty"`
2627
InstallCommand string `yaml:"install_command,omitempty"`
2728
UninstallCommands map[string]string `yaml:"uninstall_commands,omitempty"` // Store uninstall commands for when component is removed
28-
Links map[string]string `yaml:"links,omitempty"`
29+
Links config.LinkMap `yaml:"links,omitempty"`
2930
PostInstallRan bool `yaml:"post_install_ran"`
3031
PostLinkRan bool `yaml:"post_link_ran"`
3132
ContentHash string `yaml:"content_hash,omitempty"` // Hash of component content for rename detection
@@ -117,7 +118,7 @@ func (m *Manager) IsComponentInstalled(componentInfo profile.ComponentInfo) bool
117118
return exists
118119
}
119120

120-
func (m *Manager) MarkComponentInstalled(componentInfo profile.ComponentInfo, packageManager, installCommand string, links map[string]string) {
121+
func (m *Manager) MarkComponentInstalled(componentInfo profile.ComponentInfo, packageManager, installCommand string, links config.LinkMap) {
121122
key := componentInfo.FullName()
122123
m.lockFile.InstalledComponents[key] = ComponentState{
123124
ProfileName: componentInfo.ProfileName,
@@ -207,7 +208,7 @@ func (m *Manager) GetComponentState(componentInfo profile.ComponentInfo) (Compon
207208
return state, exists
208209
}
209210

210-
func (m *Manager) HasChangedSince(componentInfo profile.ComponentInfo, links map[string]string) bool {
211+
func (m *Manager) HasChangedSince(componentInfo profile.ComponentInfo, links config.LinkMap) bool {
211212
state, exists := m.GetComponentState(componentInfo)
212213
if !exists {
213214
return true // Not installed, so it's a change
@@ -218,10 +219,20 @@ func (m *Manager) HasChangedSince(componentInfo profile.ComponentInfo, links map
218219
return true
219220
}
220221

221-
for source, target := range links {
222-
if stateTarget, exists := state.Links[source]; !exists || stateTarget != target {
222+
for source, targets := range links {
223+
stateTargets, exists := state.Links[source]
224+
if !exists {
223225
return true
224226
}
227+
if len(stateTargets) != len(targets) {
228+
return true
229+
}
230+
// Check each target matches
231+
for i, target := range targets {
232+
if stateTargets[i] != target {
233+
return true
234+
}
235+
}
225236
}
226237

227238
return false
@@ -285,7 +296,7 @@ func (m *Manager) migrateRenamedComponent(oldKey string, newComponent profile.Co
285296
PackageManager: oldState.PackageManager,
286297
InstallCommand: oldState.InstallCommand,
287298
UninstallCommands: newComponent.Component.Uninstall, // Update uninstall commands to new component's
288-
Links: map[string]string{}, // Reset links so they get re-created
299+
Links: config.LinkMap{}, // Reset links so they get re-created
289300
PostInstallRan: oldState.PostInstallRan,
290301
PostLinkRan: false, // Reset post-link state since links will be re-created
291302
ContentHash: newComponent.Component.ContentHash(),

internal/state/state_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"testing"
77
"time"
88

9+
"github.com/pablopunk/dot/internal/config"
910
"github.com/pablopunk/dot/internal/profile"
1011
)
1112

@@ -92,7 +93,7 @@ func TestComponentTracking(t *testing.T) {
9293
}
9394

9495
// Mark as installed
95-
links := map[string]string{"git/.gitconfig": "~/.gitconfig"}
96+
links := config.LinkMap{"git/.gitconfig": []string{"~/.gitconfig"}}
9697
manager.MarkComponentInstalled(component, "brew", "brew install git", links)
9798

9899
// Should now be installed
@@ -193,7 +194,7 @@ func TestHasChangedSince(t *testing.T) {
193194
}
194195

195196
// Not installed - should be changed
196-
links := map[string]string{"git/.gitconfig": "~/.gitconfig"}
197+
links := config.LinkMap{"git/.gitconfig": []string{"~/.gitconfig"}}
197198
if !manager.HasChangedSince(component, links) {
198199
t.Error("HasChangedSince should return true for uninstalled component")
199200
}
@@ -204,10 +205,9 @@ func TestHasChangedSince(t *testing.T) {
204205
t.Error("HasChangedSince should return false for unchanged component")
205206
}
206207

207-
// Change links
208-
newLinks := map[string]string{
209-
"git/.gitconfig": "~/.gitconfig",
210-
"git/.gitignore": "~/.gitignore",
208+
// Change links - add another destination to same source
209+
newLinks := config.LinkMap{
210+
"git/.gitconfig": []string{"~/.gitconfig", "~/.config/git/config"},
211211
}
212212
if !manager.HasChangedSince(component, newLinks) {
213213
t.Error("HasChangedSince should return true for changed links")

0 commit comments

Comments
 (0)