Skip to content

Commit 1440981

Browse files
committed
Remove directory linking and simplify to file-only symlinks
Major rewrite to simplify cfgman's core functionality: - Remove directory linking feature - now creates individual file symlinks only - Simplify configuration to basic source/target mappings - Refactor into focused modules with clean separation of concerns - Extract common functionality (file ops, git ops, validation, etc.) - Improve error handling and user feedback throughout - Add comprehensive test coverage for all new modules
1 parent 3837a43 commit 1440981

36 files changed

+4189
-2193
lines changed

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
## [0.3.0] - 2025-06-28
11+
12+
### Changed - MAJOR REWRITE
13+
14+
This release represents a major rewrite of cfgman's core functionality.
15+
16+
- **BREAKING: File-only linking** - Removed directory linking feature. Cfgman now ONLY creates individual file symlinks, never directory symlinks. This ensures:
17+
- Consistent behavior across all operations
18+
- No conflicts between different source mappings
19+
- Ability to mix files from different sources in the same directory
20+
- Local-only files can coexist with managed configs
21+
- **Configuration-driven design** - All behavior now controlled by `.cfgman.json` with no hardcoded defaults
22+
- **Simplified codebase** - Removed obsolete features and redundant logic:
23+
- Removed LinkStrategy (file/directory linking modes)
24+
- Removed hardcoded directory list
25+
- Removed platform-specific home directory logic
26+
- Consolidated redundant orphan command logic
27+
- Simplified git operations to basic rm-with-fallback pattern
28+
- **Improved create-links workflow** - Refactored to use clear three-phase approach:
29+
1. Discovery phase - collect all files to link
30+
2. Validation phase - validate all targets before making changes
31+
3. Execution phase - create all symlinks
32+
- **Better error messages** - More descriptive errors throughout, especially for adopt command when source directory is not in mappings
33+
834
## [0.2.0] - 2025-06-28
935

1036
### Changed
@@ -46,6 +72,7 @@ Initial release of cfgman.
4672
- **Performance** - Concurrent operations for status checking
4773
- **Zero dependencies** - Pure Go implementation using only standard library
4874

75+
[0.3.0]: https://github.com/cpplain/cfgman/compare/v0.2.0...v0.3.0
4976
[0.2.0]: https://github.com/cpplain/cfgman/compare/v0.1.1...v0.2.0
5077
[0.1.1]: https://github.com/cpplain/cfgman/compare/v0.1.0...v0.1.1
5178
[0.1.0]: https://github.com/cpplain/cfgman/releases/tag/v0.1.0

README.md

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ A fast, reliable dotfile management tool. Manage your configuration files across
55
## Key Features
66

77
- **Single binary** - No dependencies required (git integration optional)
8-
- **File-level and directory-level linking** - Links files by default and directories by configuration
8+
- **Recursive file linking** - Links individual files throughout directory trees
9+
- **Smart directory adoption** - Adopting directories moves all files and creates individual symlinks
910
- **Flexible configuration** - Support for public and private config repositories
1011
- **Safety first** - Dry-run mode and clear status reporting
1112
- **Bidirectional operations** - Adopt existing files or orphan managed ones
@@ -55,13 +56,11 @@ Example configuration (after editing the template created by `cfgman init`):
5556
"link_mappings": [
5657
{
5758
"source": "home",
58-
"target": "~/",
59-
"link_as_directory": [".config/nvim"]
59+
"target": "~/"
6060
},
6161
{
6262
"source": "private/home",
63-
"target": "~/",
64-
"link_as_directory": [".ssh"]
63+
"target": "~/"
6564
}
6665
]
6766
}
@@ -70,7 +69,6 @@ Example configuration (after editing the template created by `cfgman init`):
7069
- **ignore_patterns**: Gitignore-style patterns for files to never link
7170
- **source**: Directory in your repo containing configs
7271
- **target**: Where symlinks are created (usually `~/`)
73-
- **link_as_directory**: Directories to link as complete units instead of individual files
7472

7573
## Commands
7674

@@ -86,7 +84,7 @@ cfgman init # Create a minimal .cfgman.json template
8684
cfgman status # Show all managed symlinks
8785
cfgman create-links [--dry-run] # Create symlinks from repo to home
8886
cfgman remove-links [--dry-run] # Remove all managed symlinks
89-
cfgman prune-links # Remove broken symlinks
87+
cfgman prune-links [--dry-run] # Remove broken symlinks
9088
```
9189

9290
### File Operations
@@ -111,9 +109,19 @@ cfgman help [command] # Get help
111109

112110
## How It Works
113111

114-
### File-Level and Directory-Level Linking
112+
### Recursive File Linking
115113

116-
By default, cfgman links individual files rather than entire directories. When you need to link entire directories (like `.config/nvim`), add them to `link_as_directory` in your `.cfgman.json`.
114+
cfgman recursively traverses your source directories and creates individual symlinks for each file. This approach:
115+
116+
- Allows mixing files from different sources in the same directory
117+
- Preserves your ability to have local-only files alongside managed configs
118+
- Creates parent directories as needed (never as symlinks)
119+
120+
For example, with source `home` mapped to `~/`:
121+
122+
- `home/.config/git/config``~/.config/git/config` (file symlink)
123+
- `home/.config/nvim/init.vim``~/.config/nvim/init.vim` (file symlink)
124+
- The directories `.config`, `.config/git`, and `.config/nvim` are created as regular directories, not symlinks
117125

118126
### Ignore Patterns
119127

@@ -140,8 +148,8 @@ cfgman create-links
140148
cd ~/dotfiles
141149
cfgman adopt ~/.config/newapp home
142150

143-
# If it's a directory with plugins/state, link as directory
144-
# You'll be prompted during adoption, or edit .cfgman.json manually
151+
# This will move the entire directory tree to your repo
152+
# and create symlinks for each individual file
145153
```
146154

147155
### Managing Sensitive Files

cmd/cfgman/main.go

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Package main provides the command-line interface for cfgman,
2+
// a dotfile management tool that manages configuration files
3+
// across machines using intelligent symlinks.
14
package main
25

36
import (
@@ -126,7 +129,7 @@ func handleAdopt(args []string) {
126129
sourceDir = fs.Arg(1)
127130
} else {
128131
var err error
129-
sourceDir, err = cfgman.ReadUserInputWithDefault("Enter source directory (e.g., home, private/home, work)", "home")
132+
sourceDir, err = cfgman.ReadUserInputWithDefault("Enter source directory from your config mappings", "")
130133
if err != nil {
131134
log.Fatalf("Error reading input: %v", err)
132135
}
@@ -261,24 +264,22 @@ func handleInit(args []string) {
261264

262265
fs.Usage = func() {
263266
fmt.Println("Usage: cfgman init [options]")
264-
fmt.Println("\nCreate a minimal .cfgman.json configuration template")
267+
fmt.Printf("\nCreate a minimal %s configuration template\n", cfgman.ConfigFileName)
265268
fmt.Println("\nOptions:")
266269
fs.PrintDefaults()
267270
fmt.Println("\nThis creates a template configuration file that you must edit to:")
268271
fmt.Println(" - Set the source directory (e.g., 'home')")
269272
fmt.Println(" - Set the target directory (e.g., '~/')")
270273
fmt.Println(" - Add any ignore patterns you need")
271-
fmt.Println(" - Add directories to link_as_directory if needed")
272274
}
273275

274276
fs.Parse(args)
275277

276278
// Check if config already exists
277-
cfgmanPath := filepath.Join(".", ".cfgman.json")
279+
cfgmanPath := filepath.Join(".", cfgman.ConfigFileName)
278280
if !*force {
279281
if _, err := os.Stat(cfgmanPath); err == nil {
280-
fmt.Println("Error: .cfgman.json already exists. Use --force to overwrite.")
281-
os.Exit(1)
282+
log.Fatalf("Error: %s already exists. Use --force to overwrite.", cfgman.ConfigFileName)
282283
}
283284
}
284285

@@ -287,9 +288,8 @@ func handleInit(args []string) {
287288
IgnorePatterns: []string{},
288289
LinkMappings: []cfgman.LinkMapping{
289290
{
290-
Source: "",
291-
Target: "",
292-
LinkAsDirectory: []string{},
291+
Source: "",
292+
Target: "",
293293
},
294294
},
295295
}
@@ -301,22 +301,20 @@ func handleInit(args []string) {
301301
}
302302

303303
if err := os.WriteFile(cfgmanPath, data, 0644); err != nil {
304-
log.Fatalf("Error writing .cfgman.json: %v", err)
304+
log.Fatalf("Error writing %s: %v", cfgman.ConfigFileName, err)
305305
}
306306

307-
fmt.Println("Created .cfgman.json with a minimal template.")
308-
fmt.Println("\nYou must edit .cfgman.json to configure:")
307+
fmt.Printf("Created %s with a minimal template.\n", cfgman.ConfigFileName)
308+
fmt.Printf("\nYou must edit %s to configure:\n", cfgman.ConfigFileName)
309309
fmt.Println(" - source: The directory in your repo containing config files (e.g., 'home')")
310310
fmt.Println(" - target: Where to link files to (e.g., '~/')")
311311
fmt.Println(" - ignore_patterns: Files/patterns to ignore (e.g., '.DS_Store', '*.swp')")
312-
fmt.Println(" - link_as_directory: Directories to link as whole directories instead of individual files")
313312
fmt.Println("\nExample configuration:")
314313
fmt.Println(" {")
315314
fmt.Println(" \"ignore_patterns\": [\".DS_Store\", \"*.swp\"],")
316315
fmt.Println(" \"link_mappings\": [{")
317316
fmt.Println(" \"source\": \"home\",")
318-
fmt.Println(" \"target\": \"~/\",")
319-
fmt.Println(" \"link_as_directory\": [\".config/nvim\"]")
317+
fmt.Println(" \"target\": \"~/\"")
320318
fmt.Println(" }]")
321319
fmt.Println(" }")
322320
}
@@ -326,14 +324,13 @@ func printUsage() {
326324
fmt.Println()
327325
fmt.Println("Commands:")
328326
fmt.Println(" Configuration:")
329-
fmt.Println(" init Create a minimal .cfgman.json template")
327+
fmt.Printf(" init Create a minimal %s template\n", cfgman.ConfigFileName)
330328
fmt.Println()
331329
fmt.Println(" Link Management:")
332330
fmt.Println(" status Show status of all managed symlinks")
333331
fmt.Println(" adopt PATH [SOURCE_DIR]")
334332
fmt.Println(" Adopt file/directory into repository")
335-
fmt.Println(" orphan [--dry-run] PATH")
336-
fmt.Println(" Remove file/directory from repo management")
333+
fmt.Println(" orphan PATH Remove file/directory from repo management")
337334
fmt.Println(" create-links Create symlinks from repo to home")
338335
fmt.Println(" remove-links Remove all managed symlinks")
339336
fmt.Println(" prune-links Remove broken symlinks")
@@ -345,7 +342,7 @@ func printUsage() {
345342
fmt.Println("Use 'cfgman help <command>' for more information about a command.")
346343
fmt.Println()
347344
fmt.Println("Note: cfgman must be run from within a cfgman-managed directory")
348-
fmt.Println(" (a directory containing .cfgman.json)")
345+
fmt.Printf(" (a directory containing %s)\n", cfgman.ConfigFileName)
349346
}
350347

351348
func printCommandHelp(command string) {

examples/cfgman.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,11 @@
1010
"link_mappings": [
1111
{
1212
"source": "home",
13-
"target": "~/",
14-
"link_as_directory": [".config/nvim", ".config/fish"]
13+
"target": "~/"
1514
},
1615
{
1716
"source": "private/home",
18-
"target": "~/",
19-
"link_as_directory": [".ssh", ".gnupg"]
17+
"target": "~/"
2018
}
2119
]
2220
}

0 commit comments

Comments
 (0)