Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

# dot - all your computers in one repo

> All your tools, apps, and configs with 0 dependencies
Expand Down Expand Up @@ -212,6 +213,19 @@ dot -v work

### Advanced Usage

#### Link-only mode
- Create or update symlinks defined in components without running installs or macOS defaults
- Runs postlink hooks when links are created or updated
- Supports the same profile selection and fuzzy search
- Does not persist state changes

Examples:
```bash
dot --link
dot --link work laptop
dot --link neovim
```

```bash
# Fuzzy search for components
dot git # Installs any component matching "git"
Expand Down
38 changes: 37 additions & 1 deletion cmd/dot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func main() {
defaultsImportShort = flag.Bool("i", false, "import macOS defaults from plist files")
runPostInstall = flag.Bool("postinstall", false, "run only postinstall hooks")
runPostLink = flag.Bool("postlink", false, "run only postlink hooks")
linkOnly = flag.Bool("link", false, "link configs only (no installs)")
)

// Preprocess arguments to allow flexible flag/argument ordering
Expand Down Expand Up @@ -90,6 +91,7 @@ func main() {
RemoveProfile: *removeProfile,
RunPostInstall: *runPostInstall,
RunPostLink: *runPostLink,
LinkOnly: *linkOnly,
}

// Use the preprocessed positional arguments instead of flag.Args()
Expand Down Expand Up @@ -151,6 +153,7 @@ type App struct {
RemoveProfile string
RunPostInstall bool
RunPostLink bool
LinkOnly bool
}

func (a *App) Run(args []string) error {
Expand Down Expand Up @@ -322,6 +325,39 @@ func (a *App) Run(args []string) error {
return err
}
a.printResults("PostLink Hook", results)
// Handle link-only operation
if a.LinkOnly {
if a.Verbose {
fmt.Printf("🔗 Starting link-only operation...\n")
if len(activeProfiles) > 0 {
fmt.Printf(" Active profiles: %s\n", strings.Join(activeProfiles, ", "))
} else {
fmt.Printf(" Active profiles: * (default)\n")
}
if fuzzySearch != "" {
fmt.Printf(" Fuzzy search: %s\n", fuzzySearch)
}
if a.DryRun {
fmt.Printf(" Dry run: enabled\n")
}
fmt.Println()
results, err := componentManager.LinkComponents(activeProfiles, fuzzySearch)
if err != nil {
return err
}
a.printResults("Link", results)
} else {
progressManager := ui.NewProgressManager(false)
defer progressManager.StopAll()
results, err := componentManager.LinkComponentsWithProgress(activeProfiles, fuzzySearch, progressManager)
if err != nil {
return err
}
a.printSummaryResults("Link", results)
}
return nil
}

return nil
}

Expand All @@ -336,7 +372,7 @@ func (a *App) Run(args []string) error {
}

// Persist active profiles early when provided by user (ensures they are saved even if installs fail hard)
if !a.DryRun && profilesFromUser {
if !a.DryRun && profilesFromUser && !a.LinkOnly {
stateManager, err := state.NewManager()
if err != nil {
return fmt.Errorf("failed to create state manager: %w", err)
Expand Down
24 changes: 15 additions & 9 deletions cmd/dot/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,11 +411,12 @@ func TestFlagParsing(t *testing.T) {
},
{
name: "multiple flags",
args: []string{"-v", "--dry-run", "--install"},
args: []string{"-v", "--dry-run", "--install", "--link"},
expected: map[string]bool{
"verbose": true,
"dry-run": true,
"install": true,
"link": true,
},
},
{
Expand All @@ -432,50 +433,55 @@ func TestFlagParsing(t *testing.T) {
"postlink": true,
},
},
{
name: "link flag",
args: []string{"--link"},
expected: map[string]bool{
"link": true,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset flag package state
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)

// Redefine flags for this test
verbose := flag.Bool("v", false, "verbose output")
verboseLong := flag.Bool("verbose", false, "verbose output")
dryRun := flag.Bool("dry-run", false, "preview actions without making changes")
install := flag.Bool("install", false, "force reinstall")
postinstall := flag.Bool("postinstall", false, "run only postinstall hooks")
postlink := flag.Bool("postlink", false, "run only postlink hooks")
link := flag.Bool("link", false, "link configs only (no installs)")

// Parse test args
os.Args = append([]string{"dot"}, tt.args...)
flag.Parse()

// Check expected values
if expected, exists := tt.expected["verbose"]; exists {
if (*verbose || *verboseLong) != expected {
t.Errorf("verbose flag = %v, want %v", (*verbose || *verboseLong), expected)
}
}

if expected, exists := tt.expected["dry-run"]; exists {
if *dryRun != expected {
t.Errorf("dry-run flag = %v, want %v", *dryRun, expected)
}
}

if expected, exists := tt.expected["install"]; exists {
if *install != expected {
t.Errorf("install flag = %v, want %v", *install, expected)
}
}

if expected, exists := tt.expected["link"]; exists {
if *link != expected {
t.Errorf("link flag = %v, want %v", *link, expected)
}
}
if expected, exists := tt.expected["postinstall"]; exists {
if *postinstall != expected {
t.Errorf("postinstall flag = %v, want %v", *postinstall, expected)
}
}

if expected, exists := tt.expected["postlink"]; exists {
if *postlink != expected {
t.Errorf("postlink flag = %v, want %v", *postlink, expected)
Expand Down
119 changes: 119 additions & 0 deletions internal/component/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,125 @@ func (m *Manager) uninstallComponent(comp profile.ComponentInfo, componentState
return result
}

func (m *Manager) LinkComponents(activeProfiles []string, fuzzySearch string) ([]InstallResult, error) {
components, err := m.profileManager.GetActiveComponents(activeProfiles, fuzzySearch)
if err != nil {
return nil, err
}

var results []InstallResult
for _, comp := range components {
result := InstallResult{Component: comp}

if len(comp.Component.Link) == 0 {
result.Skipped = true
results = append(results, result)
continue
}

if !m.linkManager.NeedsLinking(comp.Component.Link) {
result.Skipped = true
results = append(results, result)
continue
}

linkResults, err := m.linkManager.CreateLinks(comp.Component.Link)
result.LinkResults = linkResults
if err != nil {
result.Error = fmt.Errorf("linking failed: %w", err)
results = append(results, result)
continue
}

for _, lr := range linkResults {
if !lr.WasSuccessful() {
result.Error = fmt.Errorf("linking failed: %v", lr.Error)
break
}
}
if result.Error != nil {
results = append(results, result)
continue
}

if comp.Component.PostLink != "" && len(result.LinkResults) > 0 {
execResult := m.execManager.ExecuteShellCommand(comp.Component.PostLink)
result.PostLinkResult = &execResult
if !execResult.Success {
result.Error = fmt.Errorf("post-link hook failed: %w", execResult.Error)
}
}

results = append(results, result)
}

return results, nil
}

func (m *Manager) LinkComponentsWithProgress(activeProfiles []string, fuzzySearch string, progressManager *ui.ProgressManager) ([]InstallResult, error) {
components, err := m.profileManager.GetActiveComponents(activeProfiles, fuzzySearch)
if err != nil {
return nil, err
}

var results []InstallResult
for _, comp := range components {
result := InstallResult{Component: comp}
progress := progressManager.NewComponentProgress(comp.ComponentName)

if len(comp.Component.Link) == 0 {
result.Skipped = true
results = append(results, result)
continue
}

if !m.linkManager.NeedsLinking(comp.Component.Link) {
result.Skipped = true
results = append(results, result)
continue
}

progress.StartLinking()
linkResults, err := m.linkManager.CreateLinks(comp.Component.Link)
result.LinkResults = linkResults
if err != nil {
result.Error = fmt.Errorf("linking failed: %w", err)
progress.CompleteFailed(result.Error)
results = append(results, result)
continue
}

for _, lr := range linkResults {
if !lr.WasSuccessful() {
result.Error = fmt.Errorf("linking failed: %v", lr.Error)
progress.CompleteFailed(result.Error)
break
}
}
if result.Error != nil {
results = append(results, result)
continue
}

if comp.Component.PostLink != "" && len(result.LinkResults) > 0 {
progress.StartPostHooks()
execResult := m.execManager.ExecuteShellCommandWithProgress(comp.Component.PostLink, progress)
result.PostLinkResult = &execResult
if !execResult.Success {
result.Error = fmt.Errorf("post-link hook failed: %w", execResult.Error)
progress.CompleteFailed(result.Error)
results = append(results, result)
continue
}
}

progress.CompleteSuccess()
results = append(results, result)
}

return results, nil
}

func (m *Manager) ExportDefaults(activeProfiles []string) error {
components, err := m.profileManager.GetActiveComponents(activeProfiles, "")
if err != nil {
Expand Down
53 changes: 53 additions & 0 deletions internal/component/component_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,59 @@ func TestInstallComponentsFuzzySearch(t *testing.T) {
}
}

func TestLinkComponents(t *testing.T) {
manager, _ := createTestComponentManager(t, false)

results, err := manager.LinkComponents([]string{"work"}, "")
if err != nil {
t.Fatalf("LinkComponents() error = %v", err)
}

// Should have bash (from *) + git and docker (from work)
if len(results) != 3 {
t.Fatalf("LinkComponents() results count = %v, want 3", len(results))
}

var gitResult, dockerResult *InstallResult
for i := range results {
switch results[i].Component.ComponentName {
case "git":
gitResult = &results[i]
case "docker":
dockerResult = &results[i]
}
}

if gitResult == nil || dockerResult == nil {
t.Fatal("Expected git and docker results")
}

if gitResult.InstallResult != nil {
t.Error("git InstallResult should be nil in link-only mode")
}
if dockerResult.InstallResult != nil {
t.Error("docker InstallResult should be nil in link-only mode")
}

if len(gitResult.LinkResults) == 0 {
t.Error("git should have link results")
}
if len(dockerResult.LinkResults) == 0 {
t.Error("docker should have link results")
}

if gitResult.PostLinkResult == nil {
t.Error("git should have post-link result")
}
if dockerResult.PostLinkResult == nil {
t.Error("docker should have post-link result")
}

if len(manager.stateManager.GetInstalledComponents()) != 0 {
t.Error("state should not be modified by link-only operations")
}
}

func TestUninstallRemovedComponents(t *testing.T) {
manager, _ := createTestComponentManager(t, true)

Expand Down