Skip to content

Commit 3adda5a

Browse files
committed
feat: capture dotfiles in snapshots and show package descriptions (#7)
- Add DotfilesSnapshot to Snapshot struct, capture ~/.dotfiles git remote URL during snapshot - Populate SnapshotDotfiles/DotfilesURL in buildImportConfig so snapshot import restores dotfiles via the existing stepDotfiles flow - Match package names against the embedded catalog in the snapshot editor TUI to display descriptions
1 parent 7fbcd05 commit 3adda5a

File tree

8 files changed

+216
-4
lines changed

8 files changed

+216
-4
lines changed

internal/cli/snapshot.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,11 @@ func buildImportConfig(edited *snapshot.Snapshot, dryRun bool) *config.Config {
712712
Plugins: edited.Shell.Plugins,
713713
}
714714

715+
if edited.Dotfiles.RepoURL != "" {
716+
cfg.SnapshotDotfiles = edited.Dotfiles.RepoURL
717+
cfg.DotfilesURL = edited.Dotfiles.RepoURL
718+
}
719+
715720
cfg.SnapshotMacOS = make([]config.SnapshotMacOSPref, len(edited.MacOSPrefs))
716721
for i, p := range edited.MacOSPrefs {
717722
cfg.SnapshotMacOS[i] = config.SnapshotMacOSPref{

internal/cli/snapshot_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package cli
2+
3+
import (
4+
"testing"
5+
6+
"github.com/openbootdotdev/openboot/internal/snapshot"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestBuildImportConfig_DotfilesPopulated(t *testing.T) {
12+
snap := &snapshot.Snapshot{
13+
Packages: snapshot.PackageSnapshot{
14+
Formulae: []string{"git"},
15+
},
16+
Dotfiles: snapshot.DotfilesSnapshot{
17+
RepoURL: "https://github.com/testuser/dotfiles",
18+
},
19+
}
20+
21+
cfg := buildImportConfig(snap, false)
22+
23+
assert.Equal(t, "https://github.com/testuser/dotfiles", cfg.SnapshotDotfiles)
24+
assert.Equal(t, "https://github.com/testuser/dotfiles", cfg.DotfilesURL)
25+
}
26+
27+
func TestBuildImportConfig_EmptyDotfiles(t *testing.T) {
28+
snap := &snapshot.Snapshot{
29+
Packages: snapshot.PackageSnapshot{
30+
Formulae: []string{"git"},
31+
},
32+
}
33+
34+
cfg := buildImportConfig(snap, false)
35+
36+
assert.Empty(t, cfg.SnapshotDotfiles)
37+
assert.Empty(t, cfg.DotfilesURL)
38+
}
39+
40+
func TestBuildImportConfig_GitAndShellPopulated(t *testing.T) {
41+
snap := &snapshot.Snapshot{
42+
Git: snapshot.GitSnapshot{
43+
UserName: "Test User",
44+
UserEmail: "test@example.com",
45+
},
46+
Shell: snapshot.ShellSnapshot{
47+
OhMyZsh: true,
48+
Theme: "robbyrussell",
49+
Plugins: []string{"git"},
50+
},
51+
}
52+
53+
cfg := buildImportConfig(snap, false)
54+
55+
require.NotNil(t, cfg.SnapshotGit)
56+
assert.Equal(t, "Test User", cfg.SnapshotGit.UserName)
57+
require.NotNil(t, cfg.SnapshotShell)
58+
assert.True(t, cfg.SnapshotShell.OhMyZsh)
59+
}
60+
61+
func TestBuildImportConfig_EmptySnapshot(t *testing.T) {
62+
snap := &snapshot.Snapshot{}
63+
64+
cfg := buildImportConfig(snap, false)
65+
66+
require.NotNil(t, cfg)
67+
require.NotNil(t, cfg.SelectedPkgs)
68+
assert.Empty(t, cfg.SelectedPkgs)
69+
assert.Empty(t, cfg.OnlinePkgs)
70+
}

internal/installer/installer.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,13 @@ func RunFromSnapshot(cfg *config.Config) error {
873873
softErrs = append(softErrs, fmt.Errorf("macos: %w", err))
874874
}
875875

876+
if cfg.SnapshotDotfiles != "" {
877+
if err := stepDotfiles(cfg); err != nil {
878+
ui.Error(fmt.Sprintf("Dotfiles restore failed: %v", err))
879+
softErrs = append(softErrs, fmt.Errorf("dotfiles: %w", err))
880+
}
881+
}
882+
876883
showCompletion(cfg)
877884

878885
if len(softErrs) > 0 {

internal/snapshot/capture.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ func Capture() (*Snapshot, error) {
5353
return nil, err
5454
}
5555

56+
dotfilesSnap, err := CaptureDotfiles()
57+
if err != nil {
58+
return nil, err
59+
}
60+
5661
devTools, err := CaptureDevTools()
5762
if err != nil {
5863
return nil, err
@@ -71,6 +76,7 @@ func Capture() (*Snapshot, error) {
7176
MacOSPrefs: prefs,
7277
Shell: *shellSnap,
7378
Git: *gitSnap,
79+
Dotfiles: *dotfilesSnap,
7480
DevTools: devTools,
7581
MatchedPreset: "",
7682
CatalogMatch: CatalogMatch{
@@ -134,6 +140,12 @@ func CaptureWithProgress(callback func(step ScanStep)) (*Snapshot, error) {
134140
}},
135141
{"Shell Environment", func() (interface{}, error) { return CaptureShell() }, func(v interface{}) int { return 1 }},
136142
{"Git Configuration", func() (interface{}, error) { return CaptureGit() }, func(v interface{}) int { return 1 }},
143+
{"Dotfiles", func() (interface{}, error) { return CaptureDotfiles() }, func(v interface{}) int {
144+
if s, ok := v.(*DotfilesSnapshot); ok && s.RepoURL != "" {
145+
return 1
146+
}
147+
return 0
148+
}},
137149
{"Dev Tools", func() (interface{}, error) { return CaptureDevTools() }, func(v interface{}) int {
138150
if s, ok := v.([]DevTool); ok {
139151
return len(s)
@@ -169,7 +181,8 @@ func CaptureWithProgress(callback func(step ScanStep)) (*Snapshot, error) {
169181
prefs, _ := results[4].([]MacOSPref)
170182
shellSnap, _ := results[5].(*ShellSnapshot)
171183
gitSnap, _ := results[6].(*GitSnapshot)
172-
devTools, _ := results[7].([]DevTool)
184+
dotfilesSnap, _ := results[7].(*DotfilesSnapshot)
185+
devTools, _ := results[8].([]DevTool)
173186

174187
if formulae == nil {
175188
formulae = []string{}
@@ -192,6 +205,9 @@ func CaptureWithProgress(callback func(step ScanStep)) (*Snapshot, error) {
192205
if gitSnap == nil {
193206
gitSnap = &GitSnapshot{}
194207
}
208+
if dotfilesSnap == nil {
209+
dotfilesSnap = &DotfilesSnapshot{}
210+
}
195211
if devTools == nil {
196212
devTools = []DevTool{}
197213
}
@@ -209,6 +225,7 @@ func CaptureWithProgress(callback func(step ScanStep)) (*Snapshot, error) {
209225
MacOSPrefs: prefs,
210226
Shell: *shellSnap,
211227
Git: *gitSnap,
228+
Dotfiles: *dotfilesSnap,
212229
DevTools: devTools,
213230
MatchedPreset: "",
214231
CatalogMatch: CatalogMatch{
@@ -403,6 +420,27 @@ func CaptureDevTools() ([]DevTool, error) {
403420
return tools, nil
404421
}
405422

423+
func CaptureDotfiles() (*DotfilesSnapshot, error) {
424+
home, err := os.UserHomeDir()
425+
if err != nil {
426+
return &DotfilesSnapshot{}, nil
427+
}
428+
429+
dotfilesPath := filepath.Join(home, ".dotfiles")
430+
if _, err := os.Stat(filepath.Join(dotfilesPath, ".git")); err != nil {
431+
return &DotfilesSnapshot{}, nil
432+
}
433+
434+
out, err := exec.Command("git", "-C", dotfilesPath, "remote", "get-url", "origin").Output()
435+
if err != nil {
436+
return &DotfilesSnapshot{}, nil
437+
}
438+
439+
return &DotfilesSnapshot{
440+
RepoURL: strings.TrimSpace(string(out)),
441+
}, nil
442+
}
443+
406444
func sanitizePath(path string) string {
407445
home, err := os.UserHomeDir()
408446
if err != nil {

internal/snapshot/capture_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package snapshot
22

33
import (
44
"errors"
5+
"os"
6+
"path/filepath"
57
"testing"
68

79
"github.com/stretchr/testify/assert"
@@ -362,3 +364,37 @@ func TestCaptureWithProgress_HealthEmptyOnSuccess(t *testing.T) {
362364

363365
assert.Empty(t, failedSteps)
364366
}
367+
368+
func TestCaptureDotfiles_NoDotfilesDir(t *testing.T) {
369+
tmpDir := t.TempDir()
370+
t.Setenv("HOME", tmpDir)
371+
372+
snap, err := CaptureDotfiles()
373+
assert.NoError(t, err)
374+
require.NotNil(t, snap)
375+
assert.Empty(t, snap.RepoURL)
376+
}
377+
378+
func TestCaptureDotfiles_DotfilesDirExistsButNoGit(t *testing.T) {
379+
tmpDir := t.TempDir()
380+
t.Setenv("HOME", tmpDir)
381+
382+
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".dotfiles"), 0755))
383+
384+
snap, err := CaptureDotfiles()
385+
assert.NoError(t, err)
386+
require.NotNil(t, snap)
387+
assert.Empty(t, snap.RepoURL)
388+
}
389+
390+
func TestCaptureDotfiles_GitDirExistsButNoRemote(t *testing.T) {
391+
tmpDir := t.TempDir()
392+
t.Setenv("HOME", tmpDir)
393+
394+
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".dotfiles", ".git"), 0755))
395+
396+
snap, err := CaptureDotfiles()
397+
assert.NoError(t, err)
398+
require.NotNil(t, snap)
399+
assert.Empty(t, snap.RepoURL)
400+
}

internal/snapshot/snapshot.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,17 @@ type Snapshot struct {
1515
MacOSPrefs []MacOSPref `json:"macos_prefs"`
1616
Shell ShellSnapshot `json:"shell"`
1717
Git GitSnapshot `json:"git"`
18+
Dotfiles DotfilesSnapshot `json:"dotfiles"`
1819
DevTools []DevTool `json:"dev_tools"`
1920
MatchedPreset string `json:"matched_preset"`
2021
CatalogMatch CatalogMatch `json:"catalog_match"`
2122
Health CaptureHealth `json:"health"`
2223
}
2324

25+
type DotfilesSnapshot struct {
26+
RepoURL string `json:"repo_url,omitempty"`
27+
}
28+
2429
type PackageSnapshot struct {
2530
Formulae []string `json:"formulae"`
2631
Casks []string `json:"casks"`

internal/ui/snapshot_editor.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/charmbracelet/bubbles/key"
88
tea "github.com/charmbracelet/bubbletea"
99
"github.com/charmbracelet/lipgloss"
10+
"github.com/openbootdotdev/openboot/internal/config"
1011
"github.com/openbootdotdev/openboot/internal/snapshot"
1112
)
1213

@@ -70,23 +71,31 @@ type SnapshotEditorModel struct {
7071
}
7172

7273
func NewSnapshotEditor(snap *snapshot.Snapshot) SnapshotEditorModel {
74+
// Build a lookup from the embedded catalog for package descriptions.
75+
descMap := make(map[string]string)
76+
for _, cat := range config.Categories {
77+
for _, pkg := range cat.Packages {
78+
descMap[pkg.Name] = pkg.Description
79+
}
80+
}
81+
7382
tabs := make([]editorTab, 5)
7483

7584
formulaeItems := make([]editorItem, len(snap.Packages.Formulae))
7685
for i, pkg := range snap.Packages.Formulae {
77-
formulaeItems[i] = editorItem{name: pkg, selected: true, itemType: editorItemFormula}
86+
formulaeItems[i] = editorItem{name: pkg, description: descMap[pkg], selected: true, itemType: editorItemFormula}
7887
}
7988
tabs[0] = editorTab{name: "Formulae", icon: "🍺", items: formulaeItems, itemType: editorItemFormula}
8089

8190
caskItems := make([]editorItem, len(snap.Packages.Casks))
8291
for i, pkg := range snap.Packages.Casks {
83-
caskItems[i] = editorItem{name: pkg, selected: true, itemType: editorItemCask}
92+
caskItems[i] = editorItem{name: pkg, description: descMap[pkg], selected: true, itemType: editorItemCask}
8493
}
8594
tabs[1] = editorTab{name: "Casks", icon: "📦", items: caskItems, itemType: editorItemCask}
8695

8796
npmItems := make([]editorItem, len(snap.Packages.Npm))
8897
for i, pkg := range snap.Packages.Npm {
89-
npmItems[i] = editorItem{name: pkg, selected: true, itemType: editorItemNpm}
98+
npmItems[i] = editorItem{name: pkg, description: descMap[pkg], selected: true, itemType: editorItemNpm}
9099
}
91100
tabs[2] = editorTab{name: "NPM", icon: "📜", items: npmItems, itemType: editorItemNpm}
92101

internal/ui/snapshot_editor_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,3 +726,45 @@ func TestSnapshotEditorAddedItemVisualBadge(t *testing.T) {
726726
view := m.View()
727727
assert.Contains(t, view, "[+]")
728728
}
729+
730+
func TestNewSnapshotEditorDescriptionFromCatalog(t *testing.T) {
731+
// Find a formula in the embedded catalog.
732+
var catalogPkg, catalogDesc string
733+
for _, cat := range config.Categories {
734+
for _, pkg := range cat.Packages {
735+
if !pkg.IsCask && !pkg.IsNpm {
736+
catalogPkg = pkg.Name
737+
catalogDesc = pkg.Description
738+
break
739+
}
740+
}
741+
if catalogPkg != "" {
742+
break
743+
}
744+
}
745+
require.NotEmpty(t, catalogPkg)
746+
747+
snap := &snapshot.Snapshot{
748+
Packages: snapshot.PackageSnapshot{
749+
Formulae: []string{catalogPkg},
750+
},
751+
}
752+
753+
m := NewSnapshotEditor(snap)
754+
755+
require.Len(t, m.tabs[0].items, 1)
756+
assert.Equal(t, catalogDesc, m.tabs[0].items[0].description)
757+
}
758+
759+
func TestNewSnapshotEditorDescriptionEmptyForUnknown(t *testing.T) {
760+
snap := &snapshot.Snapshot{
761+
Packages: snapshot.PackageSnapshot{
762+
Formulae: []string{"completely-unknown-tool-xyz"},
763+
},
764+
}
765+
766+
m := NewSnapshotEditor(snap)
767+
768+
require.Len(t, m.tabs[0].items, 1)
769+
assert.Empty(t, m.tabs[0].items[0].description)
770+
}

0 commit comments

Comments
 (0)