Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/flexdot
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,13 @@ $HOME/dotfiles/
│ └── vim/
│ └── .vimrc
├── macOS/
│ └── bash/
│ └── .bash_profile
│ ├── bash/
│ │ └── .bash_profile
│ └── codex/
│ └── prompts/
│ ├── code.md
│ ├── debug.md
│ └── test.md
├── ubuntu/
│ └── bash/
│ └── .bashrc
Expand All @@ -57,9 +62,18 @@ common:
macOS:
bash:
.bash_profile: .
codex:
prompts:
"*.md": .codex/prompts
```

This will link `$HOME/dotfiles/common/bin/myscript` to `$HOME/bin/myscript`, and so on.
This will link:
- `$HOME/dotfiles/common/bin/myscript` to `$HOME/bin/myscript`
- `$HOME/dotfiles/common/vim/.vimrc` to `$HOME/.vimrc`
- `$HOME/dotfiles/macOS/bash/.bash_profile` to `$HOME/.bash_profile`
- All `.md` files in `$HOME/dotfiles/macOS/codex/prompts/` to `$HOME/.codex/prompts/`

**Wildcard patterns**: You can use the `*` wildcard to match multiple files of the same type. For example, `"*.md"` matches all Markdown files in the directory.

### Usage

Expand Down
2 changes: 0 additions & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,12 @@ func runInstall(args []string) {
os.Exit(1)
}

// Load config.yml if present
cfg, err := config.LoadConfig(dotfilesDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load config.yml: %v\n", err)
os.Exit(1)
}

// Determine homeDir and indexFile (priority: CLI > config.yml > error)
homeDir := ""
if *homeDirFlag != "" {
homeDir = *homeDirFlag
Expand Down
4 changes: 0 additions & 4 deletions internal/backup/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (

const baseDir = "backup"

// BackupFile moves the file to a new backup directory and returns the backup directory path.
func BackupFile(file string) (string, error) {
backupDir := filepath.Join(baseDir, time.Now().Format("20060102150405"))
if err := os.MkdirAll(backupDir, 0755); err != nil {
Expand All @@ -22,15 +21,13 @@ func BackupFile(file string) (string, error) {
return backupDir, nil
}

// RemoveBackupDirIfEmpty removes the backup directory if it's empty.
func RemoveBackupDirIfEmpty(backupDir string) {
entries, err := os.ReadDir(backupDir)
if err == nil && len(entries) == 0 {
os.Remove(backupDir)
}
}

// RemoveOutdatedBackups removes old backup directories if keepMaxCount is set (>0).
func RemoveOutdatedBackups(keepMaxCount int) {
if keepMaxCount <= 0 {
return
Expand Down Expand Up @@ -62,7 +59,6 @@ func RemoveOutdatedBackups(keepMaxCount int) {
}
}

// ClearAll removes all backup directories.
func ClearAll() error {
return os.RemoveAll(baseDir)
}
2 changes: 0 additions & 2 deletions internal/clearbackups/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import (
"github.com/hidakatsuya/flexdot-go/internal/backup"
)

// Run executes the clear-backups command logic.
// It removes all backup directories and returns an error if any.
func Run() error {
if err := backup.ClearAll(); err != nil {
return fmt.Errorf("failed to clear backups: %w", err)
Expand Down
1 change: 0 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ func LoadConfig(dotfilesDir string) (*Config, error) {
return &cfg, nil
}

// GetKeepMaxCount returns the keepMaxCount value or the default (10) if not set.
func (c *Config) GetKeepMaxCount() int {
if c == nil || c.KeepMaxCount == nil {
return *DefaultConfig().KeepMaxCount
Expand Down
134 changes: 134 additions & 0 deletions internal/e2e/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,3 +391,137 @@ func TestInstallMissingArgsAndConfig(t *testing.T) {
t.Errorf("expected error message to contain %q, got: %s", want, string(out))
}
}

func TestInstallWildcard(t *testing.T) {
workDir := t.TempDir()
bin := buildFlexdot(t, workDir)

// Prepare dotfiles dir with nested structure
dotfilesDir := filepath.Join(workDir, "dotfiles")
macOSDir := filepath.Join(dotfilesDir, "macOS", "codex", "prompts")
if err := os.MkdirAll(macOSDir, 0755); err != nil {
t.Fatal(err)
}

// Create multiple .md files that should match the wildcard
mdFiles := []string{"prompt1.md", "prompt2.md", "readme.md"}
for _, mdFile := range mdFiles {
filePath := filepath.Join(macOSDir, mdFile)
if err := os.WriteFile(filePath, []byte("content"), 0644); err != nil {
t.Fatal(err)
}
}

// Create a .txt file that should NOT match
txtFile := filepath.Join(macOSDir, "other.txt")
if err := os.WriteFile(txtFile, []byte("text"), 0644); err != nil {
t.Fatal(err)
}

// Prepare index.yml with wildcard pattern
indexYml := filepath.Join(dotfilesDir, "index.yml")
indexContent := `macOS:
codex:
prompts:
"*.md": .codex/prompts
`
if err := os.WriteFile(indexYml, []byte(indexContent), 0644); err != nil {
t.Fatal(err)
}

// Prepare home dir
homeDir := filepath.Join(workDir, "home")
if err := os.MkdirAll(homeDir, 0755); err != nil {
t.Fatal(err)
}

// Run flexdot install
cmd := exec.Command(bin, "install", "-H", homeDir, "index.yml")
cmd.Dir = dotfilesDir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("flexdot install with wildcard failed: %v\n%s", err, string(out))
}

// Check that all .md files are symlinked
targetDir := filepath.Join(homeDir, ".codex", "prompts")
for _, mdFile := range mdFiles {
linkPath := filepath.Join(targetDir, mdFile)
fi, err := os.Lstat(linkPath)
if err != nil {
t.Errorf("symlink not created for %s: %v", mdFile, err)
continue
}
if fi.Mode()&os.ModeSymlink == 0 {
t.Errorf("not a symlink: %v", linkPath)
continue
}
dest, err := os.Readlink(linkPath)
if err != nil {
t.Errorf("failed to read symlink for %s: %v", mdFile, err)
continue
}
expected := filepath.Join(macOSDir, mdFile)
if dest != expected {
t.Errorf("symlink for %s points to %s, want %s", mdFile, dest, expected)
}
}

// Check that .txt file is NOT symlinked
txtLinkPath := filepath.Join(targetDir, "other.txt")
if _, err := os.Lstat(txtLinkPath); err == nil {
t.Errorf(".txt file should not have been symlinked, but it was")
}
}

func TestInstallWildcardNoMatches(t *testing.T) {
workDir := t.TempDir()
bin := buildFlexdot(t, workDir)

// Prepare dotfiles dir with nested structure but no matching files
dotfilesDir := filepath.Join(workDir, "dotfiles")
macOSDir := filepath.Join(dotfilesDir, "macOS", "codex", "prompts")
if err := os.MkdirAll(macOSDir, 0755); err != nil {
t.Fatal(err)
}

// Create only .txt files (no .md files to match)
txtFile := filepath.Join(macOSDir, "other.txt")
if err := os.WriteFile(txtFile, []byte("text"), 0644); err != nil {
t.Fatal(err)
}

// Prepare index.yml with wildcard pattern
indexYml := filepath.Join(dotfilesDir, "index.yml")
indexContent := `macOS:
codex:
prompts:
"*.md": .codex/prompts
`
if err := os.WriteFile(indexYml, []byte(indexContent), 0644); err != nil {
t.Fatal(err)
}

// Prepare home dir
homeDir := filepath.Join(workDir, "home")
if err := os.MkdirAll(homeDir, 0755); err != nil {
t.Fatal(err)
}

// Run flexdot install (should succeed even with no matches)
cmd := exec.Command(bin, "install", "-H", homeDir, "index.yml")
cmd.Dir = dotfilesDir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("flexdot install with wildcard (no matches) failed: %v\n%s", err, string(out))
}

// Check that target directory is not created since there were no matches
targetDir := filepath.Join(homeDir, ".codex", "prompts")
entries, err := os.ReadDir(targetDir)
// Directory might not exist or be empty, both are acceptable
if err == nil && len(entries) > 0 {
t.Errorf("expected no symlinks to be created, but found %d entries", len(entries))
}
}

2 changes: 0 additions & 2 deletions internal/init/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import (
"gopkg.in/yaml.v3"
)

// Run executes the init subcommand logic.
// It creates a config.yml file with default values in the current directory.
func Run() error {
configPath := filepath.Join(".", "config.yml")

Expand Down
60 changes: 52 additions & 8 deletions internal/install/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ type Entry struct {
}

func Install(indexFile, homeDir, dotfilesDir string, keepMaxBackupCount int) error {
// Load index YAML
f, err := os.Open(indexFile)
if err != nil {
return fmt.Errorf("failed to open index file: %w", err)
Expand All @@ -42,7 +41,7 @@ func Install(indexFile, homeDir, dotfilesDir string, keepMaxBackupCount int) err
}

errs := 0
for _, entry := range flattenIndex(idxMap) {
for _, entry := range flattenIndex(idxMap, dotfilesDir) {
if err := installLink(entry, dotfilesDir, homeDir, keepMaxBackupCount); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
errs++
Expand Down Expand Up @@ -133,25 +132,70 @@ func handleNotExist(homeFile, dotfileAbs, homeDir string) error {
}

// FlattenIndex traverses the index map and returns a slice of dotfile/homefile path pairs.
func flattenIndex(idx map[string]any) []Entry {
func flattenIndex(idx map[string]any, dotfilesDir string) []Entry {
var result []Entry
for root, descendants := range idx {
flattenDescendants(descendants, []string{root}, &result)
flattenDescendants(descendants, []string{root}, dotfilesDir, &result)
}
return result
}

func flattenDescendants(descendants any, paths []string, result *[]Entry) {
func flattenDescendants(descendants any, paths []string, dotfilesDir string, result *[]Entry) {
switch v := descendants.(type) {
case map[string]any:
for k, val := range v {
newPaths := append(paths, k)
flattenDescendants(val, newPaths, result)
flattenDescendants(val, newPaths, dotfilesDir, result)
}
case string:
hasWildcard := false
wildcardIndex := -1

for i, p := range paths {
if strings.Contains(p, "*") {
hasWildcard = true
wildcardIndex = i
break
}
}

if hasWildcard {
expandWildcard(paths, wildcardIndex, v, dotfilesDir, result)
} else {
*result = append(*result, Entry{
DotfilePath: strings.Join(paths, "/"),
HomeFilePath: v,
})
}
}
}

func expandWildcard(paths []string, wildcardIndex int, homeFilePath string, dotfilesDir string, result *[]Entry) {
patternPath := strings.Join(paths[:wildcardIndex+1], "/")

fullPattern := filepath.Join(dotfilesDir, patternPath)

matches, err := filepath.Glob(fullPattern)
if err != nil || len(matches) == 0 {
return
}

for _, match := range matches {
relPath, err := filepath.Rel(dotfilesDir, match)
if err != nil {
continue
}

matchPath := filepath.ToSlash(relPath)

if wildcardIndex < len(paths)-1 {
remainingPaths := paths[wildcardIndex+1:]
matchPath = matchPath + "/" + strings.Join(remainingPaths, "/")
}

*result = append(*result, Entry{
DotfilePath: strings.Join(paths, "/"),
HomeFilePath: v,
DotfilePath: matchPath,
HomeFilePath: homeFilePath,
})
}
}
1 change: 0 additions & 1 deletion internal/install/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"path/filepath"
)

// OutputLog prints the result of an operation on a dotfile.
func OutputLog(homeDir, homeFile string, status *Status) {
var resultStr string
var colorCode string
Expand Down
2 changes: 0 additions & 2 deletions internal/install/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@ import (
"fmt"
)

// Options for install command.
type Options struct {
DotfilesDir string
KeepMaxBackupCount any // nil or int
}

// Run executes the install subcommand logic.
func Run(indexFile, homeDir, dotfilesDir string, keepMaxBackupCount int) error {
if err := Install(indexFile, homeDir, dotfilesDir, keepMaxBackupCount); err != nil {
return fmt.Errorf("install failed: %w", err)
Expand Down