diff --git a/README.md b/README.md index 703d285..290ea32 100644 --- a/README.md +++ b/README.md @@ -182,9 +182,32 @@ git-context remove university | `git-context current` | Show active profile | | `git-context show ` | Show profile details | | `git-context remove ` | Delete a profile | +| `git-context dir add ` | Assign a directory to a profile (auto-applied via includeIf) | +| `git-context dir remove ` | Remove a directory assignment | +| `git-context dir list` | List all directory assignments | | `git-context --help` | Show help | | `git-context --version` | Show version | +### Directory-Based Profiles + +Assign filesystem paths to profiles so git applies the right identity automatically when you're inside them — no need to remember to `switch`. + +```bash +# Make 'work' the default profile (used everywhere unless overridden) +git-context switch work + +# Assign specific directories to other profiles +git-context dir add ~/projects/personal personal +git-context dir add ~/Mollie work + +# See all assignments +git-context dir list +``` + +Under the hood git-context generates one gitconfig file per profile under `~/.config/git-context/profiles/` and rewrites `~/.gitconfig` as a thin manifest of `[include]` and `[includeIf "gitdir:..."]` blocks that git-context regenerates on every mutating command. The YAML at `~/.config/git-context/config.yaml` is always the source of truth. + +> Note: git-context owns `~/.gitconfig` end-to-end — don't run `git config --global` directly. Edit the YAML or use the CLI instead. + ## Configuration The configuration is stored in YAML format at `~/.config/git-context/config.yaml`. diff --git a/cmd/add.go b/cmd/add.go index 67bd26c..05a4e22 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/aanogueira/git-context/internal/config" + "github.com/aanogueira/git-context/internal/git" "github.com/aanogueira/git-context/internal/ui" "github.com/cockroachdb/errors" "github.com/spf13/cobra" @@ -77,6 +78,30 @@ func runAdd(cmd *cobra.Command, args []string) error { profile.URL = promptURLRewrites() } + addDirs, _ := ui.PromptConfirm("Assign directories to this profile?") + if addDirs { + for { + path, err := ui.PromptText("Directory path (leave empty to stop)", "") + if err != nil || path == "" { + break + } + + normalized, err := config.NormalizeDir(path) + if err != nil { + ui.PrintWarning(fmt.Sprintf("Skipping %q: %v", path, err)) + + continue + } + + profile.Directories = append(profile.Directories, normalized) + + more, _ := ui.PromptConfirm("Add another directory?") + if !more { + break + } + } + } + // Add the profile if err := cfg.AddProfile(profileName, profile); err != nil { ui.PrintError(fmt.Sprintf("Failed to add profile: %v", err)) @@ -91,6 +116,15 @@ func runAdd(cmd *cobra.Command, args []string) error { return errors.Wrap(err, "failed to save config") } + if len(profile.Directories) > 0 || cfg.Current != "" { + g := git.NewGit(paths.GitConfigFile) + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + ui.PrintError(fmt.Sprintf("Failed to regenerate git config: %v", err)) + + return errors.Wrap(err, "failed to regenerate git config") + } + } + ui.PrintSuccess(fmt.Sprintf("Profile '%s' created successfully", profileName)) return nil diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 670880e..5e5b4b9 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -1,11 +1,18 @@ package cmd import ( + "bytes" "os" + "os/exec" "path/filepath" + "strings" + "sync" "testing" "github.com/aanogueira/git-context/internal/config" + "github.com/aanogueira/git-context/internal/git" + "github.com/cockroachdb/errors" + "github.com/fatih/color" ) func TestRunInit(t *testing.T) { @@ -228,6 +235,86 @@ func TestRemoveProfile(t *testing.T) { }) } +func TestRemoveRegeneratesAndDropsDirectories(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, err := config.NewPaths() + if err != nil { + t.Fatalf("NewPaths: %v", err) + } + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Name: "X", Email: "x@work"}, + Directories: []string{"/tmp/work/"}, + } + cfg.Current = "work" + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + // Pre-generate so a stale work.gitconfig exists. + g := git.NewGit(paths.GitConfigFile) + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + t.Fatalf("Regenerate: %v", err) + } + + // Remove the profile (auto-confirm via removeProfileForTest helper below). + if err := removeProfileForTest(paths, "work"); err != nil { + t.Fatalf("removeProfileForTest: %v", err) + } + + loaded, err := config.LoadConfig(paths.ConfigFile) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + if _, exists := loaded.Profiles["work"]; exists { + t.Error("profile 'work' still present after remove") + } + + if _, err := os.Stat(paths.GitConfigFile); err == nil { + data, _ := os.ReadFile(paths.GitConfigFile) + if strings.Contains(string(data), "/tmp/work/") { + t.Errorf("root manifest still references removed dir:\n%s", data) + } + } +} + +// removeProfileForTest performs the same regeneration logic as runRemove +// but without the interactive prompt — used so tests can exercise the +// post-confirmation code path. +func removeProfileForTest(paths *config.Paths, name string) error { + cfg, err := config.LoadConfig(paths.ConfigFile) + if err != nil { + return errors.Wrap(err, "load config") + } + + if err := cfg.RemoveProfile(name); err != nil { + return errors.Wrap(err, "remove profile") + } + + if cfg.Current == name { + cfg.Current = "" + } + + if cfg.Previous == name { + cfg.Previous = "" + } + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + return errors.Wrap(err, "save config") + } + + if err := git.NewGit(paths.GitConfigFile).Regenerate(cfg, paths.ProfilesDir); err != nil { + return errors.Wrap(err, "regenerate git config") + } + + return nil +} + func TestListProfiles(t *testing.T) { t.Parallel() @@ -364,116 +451,106 @@ func TestShowProfile(t *testing.T) { }) } -func TestProfileToGitConfig(t *testing.T) { +func TestInitCommandExists(t *testing.T) { t.Parallel() - profile := &config.Profile{ - User: config.UserConfig{ - Name: "Test User", - Email: "test@example.com", - SigningKey: "ABCD1234", - }, - URL: []config.URLConfig{ - { - Pattern: "ssh://git@github.com/", - InsteadOf: "https://github.com/", - }, - }, - Core: map[string]any{ - "editor": "vim", - }, - Push: map[string]any{ - "default": "simple", - }, - } - - gitConfig := profileToGitConfig(profile) + // Verify init command is registered + found := false - // Verify user config - if gitConfig["user.name"] != "Test User" { - t.Error("Git config should contain user.name") - } + for _, cmd := range rootCmd.Commands() { + if cmd.Name() == "init" { + found = true - if gitConfig["user.email"] != "test@example.com" { - t.Error("Git config should contain user.email") + break + } } - if gitConfig["user.signingkey"] != "ABCD1234" { - t.Error("Git config should contain user.signingkey") + if !found { + t.Error("init command should be registered") } +} - // Verify URL rewrite - urlKey := `url "ssh://git@github.com/".insteadOf` - if gitConfig[urlKey] != "https://github.com/" { - t.Error("Git config should contain URL rewrite") - } +func TestListProfilesShowsDirsColumn(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) - // Verify other sections - if gitConfig["core.editor"] != "vim" { - t.Error("Git config should contain core.editor") - } + paths, _ := config.NewPaths() - if gitConfig["push.default"] != "simple" { - t.Error("Git config should contain push.default") + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Email: "x@work"}, + Directories: []string{"/a/", "/b/"}, } -} - -func TestAddSectionToConfig(t *testing.T) { - t.Parallel() + cfg.Profiles["personal"] = &config.Profile{User: config.UserConfig{Email: "x@home"}} - gitConfig := make(map[string]any) - - section := map[string]any{ - "editor": "vim", - "autocrlf": "input", + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) } - addSectionToConfig(gitConfig, "core", section) + out := captureStdout(t, func() { + if err := runList(nil, nil); err != nil { + t.Fatalf("runList: %v", err) + } + }) - if gitConfig["core.editor"] != "vim" { - t.Error("Should add core.editor") + if !strings.Contains(out, "Dirs") { + t.Errorf("output missing Dirs header:\n%s", out) } - if gitConfig["core.autocrlf"] != "input" { - t.Error("Should add core.autocrlf") + if !strings.Contains(out, "2") { + t.Errorf("output missing dir count of 2 for work:\n%s", out) } } -func TestAddSectionToConfigNested(t *testing.T) { - t.Parallel() - - gitConfig := make(map[string]any) - - section := map[string]any{ - "interactive": map[string]any{ - "diffFilter": "delta --color-only", - }, +// stdoutMu serializes the os.Stdout swap performed by captureStdout so +// concurrent stdout-capturing tests don't race on the global. Tests that +// use captureStdout must NOT call t.Parallel() — t.Setenv is also commonly +// used and is itself incompatible with parallel. +var stdoutMu sync.Mutex + +// captureStdout runs fn and returns whatever it wrote to os.Stdout. +// IMPORTANT: do not call from a t.Parallel() test — this swaps the +// global os.Stdout and serializes via stdoutMu. Concurrent capturers +// would still produce correct values because of the mutex, but other +// non-capturing parallel tests printing to stdout during fn will have +// their output silently captured, which is rarely what you want. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + stdoutMu.Lock() + defer stdoutMu.Unlock() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) } - addSectionToConfig(gitConfig, "add", section) + old := os.Stdout + // fatih/color caches its output writer at init, so callers like + // ui.PrintInfo bypass our os.Stdout swap unless we redirect here too. + oldColor := color.Output + os.Stdout = w + color.Output = w - if gitConfig["add.interactive.diffFilter"] != "delta --color-only" { - t.Error("Should add nested config values") - } -} + defer func() { + os.Stdout = old + color.Output = oldColor + }() -func TestInitCommandExists(t *testing.T) { - t.Parallel() + done := make(chan string) - // Verify init command is registered - found := false + go func() { + var buf bytes.Buffer - for _, cmd := range rootCmd.Commands() { - if cmd.Name() == "init" { - found = true + _, _ = buf.ReadFrom(r) + done <- buf.String() + }() - break - } - } + fn() - if !found { - t.Error("init command should be registered") - } + _ = w.Close() + + return <-done } func TestRootCommandMetadata(t *testing.T) { @@ -492,129 +569,121 @@ func TestRootCommandMetadata(t *testing.T) { } } -func TestProfileToGitConfigAllSections(t *testing.T) { - t.Parallel() +func TestShowDisplaysDirectories(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) - profile := &config.Profile{ - User: config.UserConfig{ - Name: "Test User", - Email: "test@example.com", - SigningKey: "KEY123", - }, - HTTP: map[string]any{ - "postBuffer": "524288000", - }, - Core: map[string]any{ - "editor": "vim", - }, - Interactive: map[string]any{ - "singleKey": "true", - }, - Add: map[string]any{ - "interactive": map[string]any{ - "useBuiltin": "false", - }, - }, - Delta: map[string]any{ - "navigate": "true", - }, - Push: map[string]any{ - "default": "current", - }, - Merge: map[string]any{ - "conflictStyle": "diff3", - }, - Commit: map[string]any{ - "gpgsign": "true", - }, - GPG: map[string]any{ - "program": "gpg2", - }, - Pull: map[string]any{ - "rebase": "true", - }, - Rerere: map[string]any{ - "enabled": "true", - }, - Column: map[string]any{ - "ui": "auto", - }, - Branch: map[string]any{ - "autoSetupRebase": "always", - }, - Init: map[string]any{ - "defaultBranch": "main", - }, - } + paths, _ := config.NewPaths() - gitConfig := profileToGitConfig(profile) + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Name: "X", Email: "x@work"}, + Directories: []string{"/Users/x/work/", "/Users/x/Mollie/"}, + } - // Verify all sections are present - sections := []string{ - "user.name", "user.email", "user.signingkey", - "http.postBuffer", - "core.editor", - "interactive.singleKey", - "add.interactive.useBuiltin", - "delta.navigate", - "push.default", - "merge.conflictStyle", - "commit.gpgsign", - "gpg.program", - "pull.rebase", - "rerere.enabled", - "column.ui", - "branch.autoSetupRebase", - "init.defaultBranch", + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) } - for _, key := range sections { - if _, exists := gitConfig[key]; !exists { - t.Errorf("Git config should contain key: %s", key) + out := captureStdout(t, func() { + if err := runShow(nil, []string{"work"}); err != nil { + t.Fatalf("runShow: %v", err) } + }) + + if !strings.Contains(out, "Directories") { + t.Errorf("output missing Directories label:\n%s", out) + } + + if !strings.Contains(out, "/Users/x/work/") { + t.Errorf("output missing assigned dir:\n%s", out) + } + + if !strings.Contains(out, "/Users/x/Mollie/") { + t.Errorf("output missing assigned dir:\n%s", out) } } -func TestProfileToGitConfigEmptySections(t *testing.T) { - t.Parallel() +func TestCurrentShowsEffectiveProfileInCwd(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } - profile := &config.Profile{ - User: config.UserConfig{ - Name: "Test", - Email: "test@test.com", - }, - // All other sections empty/nil + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Name: "Andre", Email: "a@work.com"}, + Directories: []string{}, + } + cfg.Profiles["personal"] = &config.Profile{ + User: config.UserConfig{Name: "Andre", Email: "a@home.com"}, + Directories: []string{}, } + cfg.Current = "work" - gitConfig := profileToGitConfig(profile) + repoDir := filepath.Join(tmpHome, "personal-repo") + if err := os.MkdirAll(repoDir, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } - // Should have user config - if gitConfig["user.name"] != "Test" { - t.Error("Should have user.name") + if out, err := exec.Command("git", "-C", repoDir, "init").CombinedOutput(); err != nil { + t.Fatalf("git init: %v\n%s", err, out) } - // Other sections should not add keys - if val, exists := gitConfig["http.something"]; exists { - t.Errorf("Should not have http keys, got: %v", val) + cfg.Profiles["personal"].Directories = []string{repoDir + "/"} + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) } -} -func TestAddSectionToConfigRecursiveDeepNesting(t *testing.T) { - t.Parallel() + g := git.NewGit(paths.GitConfigFile) + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + t.Fatalf("Regenerate: %v", err) + } - gitConfig := make(map[string]any) + t.Chdir(repoDir) - values := map[string]any{ - "level1": map[string]any{ - "level2": map[string]any{ - "level3": "deepvalue", - }, - }, + out := captureStdout(t, func() { + if err := runCurrent(nil, nil); err != nil { + t.Fatalf("runCurrent: %v", err) + } + }) + + if !strings.Contains(out, "Effective in") { + t.Errorf("output missing 'Effective in' line:\n%s", out) } - addSectionToConfigRecursive(gitConfig, "section", values) + if !strings.Contains(out, "personal") { + t.Errorf("expected 'personal' to be effective in this dir:\n%s", out) + } +} + +func TestEffectiveProfileInCwdRejectsNonFileOrigin(t *testing.T) { + // Hard to fake `git config --show-origin` without actually running git, + // so this test serves as a placeholder asserting the function returns + // "" when no git command is available (i.e. when we're in a directory + // where the git invocation fails). The richer behavior is exercised by + // TestCurrentShowsEffectiveProfileInCwd. + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + // Run from a temp dir that is NOT a git repo. git config will fail. + t.Chdir(tmpHome) + + paths, _ := config.NewPaths() + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{User: config.UserConfig{Name: "X"}} - if gitConfig["section.level1.level2.level3"] != "deepvalue" { - t.Error("Should handle deep nesting") + if got := effectiveProfileInCwd(paths, cfg); got != "" { + t.Errorf("effectiveProfileInCwd outside a repo = %q, want empty", got) } } diff --git a/cmd/current.go b/cmd/current.go index eb3604e..f48694e 100644 --- a/cmd/current.go +++ b/cmd/current.go @@ -2,6 +2,10 @@ package cmd import ( "fmt" + "os" + "os/exec" + "path/filepath" + "strings" "github.com/aanogueira/git-context/internal/config" "github.com/aanogueira/git-context/internal/ui" @@ -12,12 +16,10 @@ import ( var currentCmd = &cobra.Command{ Use: "current", Short: "Show the currently active profile", - Long: `Display which git configuration profile is currently active.`, + Long: `Display which git configuration profile is currently active globally and effective in the current directory.`, RunE: runCurrent, } -// runCurrent handles the 'current' command to show the currently active profile. -// It compares git configuration with saved profiles to determine which is active. func runCurrent(cmd *cobra.Command, args []string) error { paths, err := config.NewPaths() if err != nil { @@ -33,25 +35,83 @@ func runCurrent(cmd *cobra.Command, args []string) error { return errors.Wrap(err, "failed to load config") } + ui.PrintHeader("Current Profile") + if cfg.Current == "" { ui.PrintWarning("No active profile set") + } else { + profile, err := cfg.GetProfile(cfg.Current) + if err != nil { + ui.PrintError(fmt.Sprintf("Active profile not found: %v", err)) - return nil + return errors.Wrap(err, "failed to get active profile") + } + + ui.PrintInfo("Default: " + cfg.Current) + ui.PrintInfo("Name: " + profile.User.Name) + ui.PrintInfo("Email: " + profile.User.Email) } - profile, err := cfg.GetProfile(cfg.Current) + if effective := effectiveProfileInCwd(paths, cfg); effective != "" { + ui.PrintInfo("Effective in " + currentDir() + ": " + effective) + } + + return nil +} + +// effectiveProfileInCwd asks git which file user.email comes from in the +// current working directory. If the answer is one of our per-profile files, +// returns the profile name; otherwise returns "". +func effectiveProfileInCwd(paths *config.Paths, cfg *config.Config) string { + cmd := exec.Command("git", "config", "--show-origin", "user.email") + + out, err := cmd.Output() if err != nil { - ui.PrintError(fmt.Sprintf("Active profile not found: %v", err)) + return "" + } + + line := strings.TrimSpace(string(out)) - return errors.Wrap(err, "failed to get active profile") + parts := strings.SplitN(line, "\t", 2) + if len(parts) == 0 { + return "" } - ui.PrintHeader("Current Profile") - ui.PrintInfo("Profile: " + cfg.Current) - ui.PrintInfo("Name: " + profile.User.Name) - ui.PrintInfo("Email: " + profile.User.Email) + if !strings.HasPrefix(parts[0], "file:") { + return "" + } - return nil + origin := resolveSymlinks(strings.TrimPrefix(parts[0], "file:")) + + for name := range cfg.Profiles { + profilePath := resolveSymlinks(filepath.Join(paths.ProfilesDir, name+".gitconfig")) + if origin == profilePath { + return name + } + } + + return "" +} + +// resolveSymlinks returns the symlink-resolved absolute path. On error +// (e.g. file does not exist) it returns the input unchanged so we can +// still attempt a literal comparison. +func resolveSymlinks(path string) string { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return path + } + + return resolved +} + +func currentDir() string { + d, err := os.Getwd() + if err != nil { + return "." + } + + return d } func init() { diff --git a/cmd/dir.go b/cmd/dir.go new file mode 100644 index 0000000..260becc --- /dev/null +++ b/cmd/dir.go @@ -0,0 +1,203 @@ +package cmd + +import ( + "fmt" + "os" + "sort" + + "github.com/aanogueira/git-context/internal/config" + "github.com/aanogueira/git-context/internal/git" + "github.com/aanogueira/git-context/internal/ui" + "github.com/cockroachdb/errors" + "github.com/spf13/cobra" +) + +var dirCmd = &cobra.Command{ + Use: "dir", + Short: "Manage directory-to-profile assignments", + Long: `Assign filesystem paths to git profiles. When inside an assigned directory, ` + + `git uses that profile via includeIf.`, +} + +var dirAddCmd = &cobra.Command{ + Use: "add [path] [profile]", + Short: "Assign a directory to a profile", + Args: cobra.ExactArgs(2), + RunE: runDirAdd, +} + +var dirRemoveCmd = &cobra.Command{ + Use: "remove [path]", + Short: "Remove a directory assignment", + Args: cobra.ExactArgs(1), + RunE: runDirRemove, +} + +var dirListCmd = &cobra.Command{ + Use: "list", + Short: "List directory assignments", + RunE: runDirList, +} + +// runDirAdd handles the 'dir add' command to assign a directory path to a profile. +// It normalizes the path, persists the assignment, and regenerates the git config manifest. +func runDirAdd(cmd *cobra.Command, args []string) error { + rawPath, profileName := args[0], args[1] + + paths, err := config.NewPaths() + if err != nil { + ui.PrintError(fmt.Sprintf("Failed to get paths: %v", err)) + + return errors.Wrap(err, "failed to get paths") + } + + cfg, err := config.LoadConfig(paths.ConfigFile) + if err != nil { + ui.PrintError(fmt.Sprintf("Failed to load config: %v", err)) + + return errors.Wrap(err, "failed to load config") + } + + normalized, err := config.NormalizeDir(rawPath) + if err != nil { + ui.PrintError(err.Error()) + + return errors.Wrap(err, "failed to normalize directory") + } + + if err := cfg.AssignDir(normalized, profileName); err != nil { + ui.PrintError(err.Error()) + + return errors.Wrap(err, "failed to assign directory") + } + + if _, err := os.Stat(normalized); os.IsNotExist(err) { + ui.PrintWarning(fmt.Sprintf("Directory %s does not exist yet (assignment saved anyway)", normalized)) + } + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + ui.PrintError(fmt.Sprintf("Failed to save config: %v", err)) + + return errors.Wrap(err, "failed to save config") + } + + g := git.NewGit(paths.GitConfigFile) + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + ui.PrintError(fmt.Sprintf("Failed to regenerate git config: %v", err)) + + return errors.Wrap(err, "failed to regenerate git config") + } + + ui.PrintSuccess(fmt.Sprintf("Assigned %s → %s", normalized, profileName)) + + if cfg.Current == "" { + ui.PrintWarning("no default profile set; run 'switch ' to apply one outside assigned directories") + } + + return nil +} + +// runDirRemove handles the 'dir remove' command to remove an existing directory +// assignment and regenerate the git config manifest. +func runDirRemove(cmd *cobra.Command, args []string) error { + rawPath := args[0] + + paths, err := config.NewPaths() + if err != nil { + ui.PrintError(fmt.Sprintf("Failed to get paths: %v", err)) + + return errors.Wrap(err, "failed to get paths") + } + + cfg, err := config.LoadConfig(paths.ConfigFile) + if err != nil { + ui.PrintError(fmt.Sprintf("Failed to load config: %v", err)) + + return errors.Wrap(err, "failed to load config") + } + + normalized, err := config.NormalizeDir(rawPath) + if err != nil { + ui.PrintError(err.Error()) + + return errors.Wrap(err, "failed to normalize directory") + } + + if err := cfg.UnassignDir(normalized); err != nil { + ui.PrintError(err.Error()) + + return errors.Wrap(err, "failed to unassign directory") + } + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + ui.PrintError(fmt.Sprintf("Failed to save config: %v", err)) + + return errors.Wrap(err, "failed to save config") + } + + g := git.NewGit(paths.GitConfigFile) + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + ui.PrintError(fmt.Sprintf("Failed to regenerate git config: %v", err)) + + return errors.Wrap(err, "failed to regenerate git config") + } + + ui.PrintSuccess("Removed assignment for " + normalized) + + return nil +} + +// runDirList handles the 'dir list' command to display all directory-to-profile +// assignments along with whether each directory currently exists on disk. +func runDirList(cmd *cobra.Command, args []string) error { + paths, err := config.NewPaths() + if err != nil { + ui.PrintError(fmt.Sprintf("Failed to get paths: %v", err)) + + return errors.Wrap(err, "failed to get paths") + } + + cfg, err := config.LoadConfig(paths.ConfigFile) + if err != nil { + ui.PrintError(fmt.Sprintf("Failed to load config: %v", err)) + + return errors.Wrap(err, "failed to load config") + } + + assignments := cfg.AssignmentMap() + if len(assignments) == 0 { + ui.PrintWarning("No directory assignments. Use 'git-context dir add ' to add one.") + + return nil + } + + keys := make([]string, 0, len(assignments)) + for k := range assignments { + keys = append(keys, k) + } + + sort.Strings(keys) + + rows := make([][]string, 0, len(keys)) + + for _, dir := range keys { + mark := "✓" + if _, err := os.Stat(dir); os.IsNotExist(err) { + mark = "✗" + } + + rows = append(rows, []string{dir, assignments[dir], mark}) + } + + ui.PrintHeader("Directory Assignments") + ui.PrintTable([]string{"Directory", "Profile", "Exists"}, rows) + + return nil +} + +func init() { + dirCmd.AddCommand(dirAddCmd) + dirCmd.AddCommand(dirRemoveCmd) + dirCmd.AddCommand(dirListCmd) + rootCmd.AddCommand(dirCmd) +} diff --git a/cmd/dir_test.go b/cmd/dir_test.go new file mode 100644 index 0000000..cda4320 --- /dev/null +++ b/cmd/dir_test.go @@ -0,0 +1,165 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aanogueira/git-context/internal/config" +) + +func TestDirAddAssignsAndRegenerates(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{User: config.UserConfig{Name: "X", Email: "x@work"}} + cfg.Current = "work" + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + if err := runDirAdd(nil, []string{"/tmp/myrepo", "work"}); err != nil { + t.Fatalf("runDirAdd: %v", err) + } + + loaded, _ := config.LoadConfig(paths.ConfigFile) + if got := loaded.Profiles["work"].Directories; len(got) != 1 || got[0] != "/tmp/myrepo/" { + t.Errorf("Directories = %v, want [/tmp/myrepo/]", got) + } + + root, err := os.ReadFile(paths.GitConfigFile) + if err != nil { + t.Fatalf("read root: %v", err) + } + + if !strings.Contains(string(root), `gitdir:/tmp/myrepo/`) { + t.Errorf("root manifest missing includeIf:\n%s", root) + } +} + +func TestDirAddRejectsDuplicate(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{Directories: []string{"/tmp/x/"}} + cfg.Profiles["personal"] = &config.Profile{} + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + err := runDirAdd(nil, []string{"/tmp/x", "personal"}) + if err == nil { + t.Fatal("expected error for duplicate, got nil") + } + + if !strings.Contains(err.Error(), "already assigned") { + t.Errorf("error = %q, want it to mention 'already assigned'", err.Error()) + } +} + +func TestDirAddWarnsWhenNoDefaultProfile(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{User: config.UserConfig{Name: "X"}} + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + out := captureStdout(t, func() { + if err := runDirAdd(nil, []string{"/tmp/x", "work"}); err != nil { + t.Fatalf("runDirAdd: %v", err) + } + }) + + if !strings.Contains(out, "no default profile set") { + t.Errorf("missing default-profile warning:\n%s", out) + } +} + +func TestDirRemoveUnassignsAndRegenerates(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Name: "X", Email: "x@work"}, + Directories: []string{"/tmp/myrepo/"}, + } + cfg.Current = "work" + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + if err := runDirRemove(nil, []string{"/tmp/myrepo"}); err != nil { + t.Fatalf("runDirRemove: %v", err) + } + + loaded, _ := config.LoadConfig(paths.ConfigFile) + if got := loaded.Profiles["work"].Directories; len(got) != 0 { + t.Errorf("Directories = %v, want empty", got) + } + + if data, err := os.ReadFile(paths.GitConfigFile); err == nil { + if strings.Contains(string(data), "/tmp/myrepo") { + t.Errorf("manifest still references removed dir:\n%s", data) + } + } +} + +func TestDirListShowsAssignments(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + existsDir := filepath.Join(tmpHome, "exists") + _ = os.MkdirAll(existsDir, 0o755) + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + Directories: []string{existsDir + "/", "/nonexistent/path/"}, + } + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + out := captureStdout(t, func() { + if err := runDirList(nil, nil); err != nil { + t.Fatalf("runDirList: %v", err) + } + }) + + if !strings.Contains(out, existsDir) { + t.Errorf("output missing existing dir:\n%s", out) + } + + if !strings.Contains(out, "/nonexistent/path/") { + t.Errorf("output missing nonexistent dir:\n%s", out) + } + + if !strings.Contains(out, "✓") { + t.Errorf("expected ✓ for existing dir:\n%s", out) + } + + if !strings.Contains(out, "✗") { + t.Errorf("expected ✗ for missing dir:\n%s", out) + } +} diff --git a/cmd/integration_test.go b/cmd/integration_test.go new file mode 100644 index 0000000..db7bb9d --- /dev/null +++ b/cmd/integration_test.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/aanogueira/git-context/internal/config" +) + +// TestEndToEndDirectoryAssignment exercises the full lifecycle: switch to a +// default profile, assign a directory to a different profile, verify that +// inside the assigned directory git resolves the right user.email. +func TestEndToEndDirectoryAssignment(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + + // Resolve symlinks because git resolves gitdir to its real path; on macOS + // t.TempDir() lives under /var/folders which is a symlink to /private/var. + tmpHome, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + + t.Setenv("HOME", tmpHome) + + paths, err := config.NewPaths() + if err != nil { + t.Fatalf("NewPaths: %v", err) + } + + // Set up two profiles in config.yaml. + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Name: "Andre", Email: "a@work.com"}, + } + cfg.Profiles["personal"] = &config.Profile{ + User: config.UserConfig{Name: "Andre", Email: "a@home.com"}, + } + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + // Switch makes 'work' the default and regenerates. + if err := runSwitch(nil, []string{"work"}); err != nil { + t.Fatalf("runSwitch: %v", err) + } + + // Create a real git repo and assign it to 'personal'. + repo := filepath.Join(tmpHome, "personal-repo") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + + if out, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil { + t.Fatalf("git init: %v\n%s", err, out) + } + + if err := runDirAdd(nil, []string{repo, "personal"}); err != nil { + t.Fatalf("runDirAdd: %v", err) + } + + // Outside the repo (use HOME), default 'work' should be effective. + out, err := exec.Command("git", "-C", tmpHome, "config", "user.email").Output() + if err != nil { + t.Fatalf("git config user.email outside: %v", err) + } + + if got := strings.TrimSpace(string(out)); got != "a@work.com" { + t.Errorf("default user.email = %q, want a@work.com", got) + } + + // Inside the repo, 'personal' should override. + out, err = exec.Command("git", "-C", repo, "config", "user.email").Output() + if err != nil { + t.Fatalf("git config user.email inside: %v", err) + } + + if got := strings.TrimSpace(string(out)); got != "a@home.com" { + t.Errorf("override user.email = %q, want a@home.com", got) + } + + // dir remove undoes the override. + if err := runDirRemove(nil, []string{repo}); err != nil { + t.Fatalf("runDirRemove: %v", err) + } + + out, err = exec.Command("git", "-C", repo, "config", "user.email").Output() + if err != nil { + t.Fatalf("git config user.email after remove: %v", err) + } + + if got := strings.TrimSpace(string(out)); got != "a@work.com" { + t.Errorf("after dir remove, user.email = %q, want a@work.com", got) + } + + // Sanity: profile files exist on disk. + for _, name := range []string{"work", "personal"} { + if _, err := os.Stat(filepath.Join(paths.ProfilesDir, name+".gitconfig")); err != nil { + t.Errorf("expected %s.gitconfig: %v", name, err) + } + } + + // Sanity: per-profile file content matches the profile. + workFile := filepath.Join(paths.ProfilesDir, "work.gitconfig") + + data, _ := os.ReadFile(workFile) + if !strings.Contains(string(data), "a@work.com") { + t.Errorf("work.gitconfig missing email:\n%s", data) + } +} diff --git a/cmd/list.go b/cmd/list.go index 16257e5..f4bba23 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "sort" + "strconv" "github.com/aanogueira/git-context/internal/config" "github.com/aanogueira/git-context/internal/ui" @@ -55,14 +56,21 @@ func runList(cmd *cobra.Command, args []string) error { p, _ := cfg.GetProfile(profile) email := "" - if p != nil && p.User.Email != "" { - email = p.User.Email + + dirs := "0" + + if p != nil { + if p.User.Email != "" { + email = p.User.Email + } + + dirs = strconv.Itoa(len(p.Directories)) } - rows[i] = []string{profile, email, status} + rows[i] = []string{profile, email, dirs, status} } - ui.PrintTable([]string{"Profile", "Email", "Status"}, rows) + ui.PrintTable([]string{"Profile", "Email", "Dirs", "Status"}, rows) return nil } diff --git a/cmd/remove.go b/cmd/remove.go index aa3a674..b07243c 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/aanogueira/git-context/internal/config" + "github.com/aanogueira/git-context/internal/git" "github.com/aanogueira/git-context/internal/ui" "github.com/cockroachdb/errors" "github.com/spf13/cobra" @@ -43,10 +44,19 @@ func runRemove(cmd *cobra.Command, args []string) error { return errors.Wrap(err, "profile not found") } - // Confirm removal - confirm, err := ui.PromptConfirm( - fmt.Sprintf("Are you sure you want to remove profile '%s'?", profileName), - ) + hasDirs := len(cfg.Profiles[profileName].Directories) > 0 + + prompt := fmt.Sprintf("Are you sure you want to remove profile '%s'?", profileName) + if hasDirs { + prompt = fmt.Sprintf( + "Profile '%s' has %d assigned director%s. Remove anyway?", + profileName, + len(cfg.Profiles[profileName].Directories), + plural(len(cfg.Profiles[profileName].Directories), "y", "ies"), + ) + } + + confirm, err := ui.PromptConfirm(prompt) if err != nil { ui.PrintWarning("Removal canceled") @@ -59,25 +69,46 @@ func runRemove(cmd *cobra.Command, args []string) error { return nil } - // Remove the profile if err := cfg.RemoveProfile(profileName); err != nil { ui.PrintError(fmt.Sprintf("Failed to remove profile: %v", err)) return errors.Wrap(err, "failed to remove profile") } - // Save config + if cfg.Current == profileName { + cfg.Current = "" + } + + if cfg.Previous == profileName { + cfg.Previous = "" + } + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { ui.PrintError(fmt.Sprintf("Failed to save config: %v", err)) return errors.Wrap(err, "failed to save config") } + g := git.NewGit(paths.GitConfigFile) + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + ui.PrintError(fmt.Sprintf("Failed to regenerate git config: %v", err)) + + return errors.Wrap(err, "failed to regenerate git config") + } + ui.PrintSuccess(fmt.Sprintf("Profile '%s' removed successfully", profileName)) return nil } +func plural(n int, one, many string) string { + if n == 1 { + return one + } + + return many +} + func init() { rootCmd.AddCommand(removeCmd) } diff --git a/cmd/show.go b/cmd/show.go index c6da8f9..5baffbd 100644 --- a/cmd/show.go +++ b/cmd/show.go @@ -66,6 +66,15 @@ func runShow(cmd *cobra.Command, args []string) error { } } + if len(profile.Directories) > 0 { + fmt.Println() + ui.PrintInfo("Directories:") + + for _, dir := range profile.Directories { + ui.PrintInfo(" " + dir) + } + } + return nil } diff --git a/cmd/switch.go b/cmd/switch.go index 1cc7f71..09cad18 100644 --- a/cmd/switch.go +++ b/cmd/switch.go @@ -56,38 +56,26 @@ func runSwitch(cmd *cobra.Command, args []string) error { ui.PrintHeader("Switching to Profile: " + profileName) - // Create Git instance g := git.NewGit(paths.GitConfigFile) - // Backup current config if err := g.BackupConfig(paths.GitConfigBackup); err != nil { ui.PrintWarning(fmt.Sprintf("Failed to backup git config: %v", err)) } else { ui.PrintInfo("Backed up git config to " + paths.GitConfigBackup) } - // Build the merged configuration - mergedProfile, err := cfg.Merge(profileName) - if err != nil { - ui.PrintError(fmt.Sprintf("Failed to merge configurations: %v", err)) - - return errors.Wrap(err, "failed to merge configurations") + if cfg.Current != "" && cfg.Current != profileName { + cfg.Previous = cfg.Current } - // Convert profile to git config format and write - gitConfig := profileToGitConfig(mergedProfile) - if err := g.WriteConfig(gitConfig); err != nil { - ui.PrintError(fmt.Sprintf("Failed to write git config: %v", err)) + cfg.Current = profileName - return errors.Wrap(err, "failed to write git config") - } + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + ui.PrintError(fmt.Sprintf("Failed to regenerate git config: %v", err)) - // Update current profile - if cfg.Current != "" && cfg.Current != profileName { - cfg.Previous = cfg.Current + return errors.Wrap(err, "failed to regenerate git config") } - cfg.Current = profileName if err := cfg.SaveConfig(paths.ConfigFile); err != nil { ui.PrintError(fmt.Sprintf("Failed to save config: %v", err)) @@ -100,71 +88,6 @@ func runSwitch(cmd *cobra.Command, args []string) error { return nil } -// profileToGitConfig converts a Profile to a git configuration map. -// It maps profile fields to git config keys (user.name, user.email, etc.). -func profileToGitConfig(profile *config.Profile) map[string]any { - gitConfig := make(map[string]any) - - // User section - if profile.User.Name != "" { - gitConfig["user.name"] = profile.User.Name - } - - if profile.User.Email != "" { - gitConfig["user.email"] = profile.User.Email - } - - if profile.User.SigningKey != "" { - gitConfig["user.signingkey"] = profile.User.SigningKey - } - - // URL rewrites - for _, url := range profile.URL { - key := fmt.Sprintf("url \"%s\".insteadOf", url.Pattern) - gitConfig[key] = url.InsteadOf - } - - // Dynamically add all sections from the profile - for _, section := range config.ConfigSections { - if sectionMap := profile.GetSection(section); sectionMap != nil { - addSectionToConfig(gitConfig, section, sectionMap) - } - } - - return gitConfig -} - -// addSectionToConfig adds a section with values to the git configuration map. -// It delegates to addSectionToConfigRecursive for hierarchical key handling. -func addSectionToConfig( - config map[string]any, - section string, - values map[string]any, -) { - addSectionToConfigRecursive(config, section, values) -} - -// addSectionToConfigRecursive recursively adds nested configuration values. -// It handles dot-separated keys by creating nested maps as needed. -func addSectionToConfigRecursive( - config map[string]any, - prefix string, - values map[string]any, -) { - for k, v := range values { - // If the value is a map, it's a subsection - just continue with dot notation - // e.g., add.interactive, delta.decorations, delta.interactive - if m, ok := v.(map[string]any); ok { - key := fmt.Sprintf("%s.%s", prefix, k) - addSectionToConfigRecursive(config, key, m) - } else { - // Leaf value - add it directly - key := fmt.Sprintf("%s.%s", prefix, k) - config[key] = v - } - } -} - func init() { rootCmd.AddCommand(switchCmd) } diff --git a/docs/superpowers/plans/2026-05-04-directory-based-profile-assignment.md b/docs/superpowers/plans/2026-05-04-directory-based-profile-assignment.md new file mode 100644 index 0000000..dbd6163 --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-directory-based-profile-assignment.md @@ -0,0 +1,2616 @@ +# Directory-Based Profile Assignment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make repos under user-configured paths automatically use a chosen git profile via git's native `includeIf "gitdir:..."` mechanism, while a configured default profile applies everywhere else. + +**Architecture:** Per-profile gitconfig files generated at `~/.config/git-context/profiles/.gitconfig`. `~/.gitconfig` becomes a thin manifest (header marker + unconditional `[include]` for the default + one `[includeIf]` per assigned directory). Every mutating command (`switch`, `add` with dirs, `remove`, `dir add/remove`) regenerates both the profile files and the manifest. + +**Tech Stack:** Go 1.25, `cobra` (CLI), `yaml.v3`, `cockroachdb/errors`. Tests use the standard `testing` package and follow the existing `t.Parallel()` + `t.TempDir()` patterns in this repo. + +**Spec:** `docs/superpowers/specs/2026-05-04-directory-based-profile-assignment-design.md` + +--- + +## File Structure + +**Created:** +- `internal/config/dirs.go` — directory normalization, lookup, assignment helpers +- `internal/config/dirs_test.go` — tests for the above +- `cmd/dir.go` — `dir` parent command + `add`/`remove`/`list` subcommands +- `cmd/dir_test.go` — tests for the above + +**Modified:** +- `internal/config/config.go` — add `Directories []string` to `Profile` +- `internal/config/paths.go` — add `ProfilesDir` field +- `internal/git/git.go` — add `WriteProfileFile`, `WriteRootConfig`, `Regenerate` +- `internal/git/git_test.go` — tests for new methods +- `cmd/switch.go` — call `Regenerate` instead of `WriteConfig` +- `cmd/add.go` — optional "assign directories" prompt; trigger regenerate when used +- `cmd/remove.go` — confirm if profile has directories; regenerate on success +- `cmd/list.go` — add "Dirs" column +- `cmd/show.go` — display assigned directories +- `cmd/current.go` — show "Effective in " line via `git config --show-origin user.email` +- `cmd/cmd_test.go` — tests for changed commands + +--- + +## Task 1: Add `Directories` field to `Profile` + +**Files:** +- Modify: `internal/config/config.go:16-42` +- Modify: `internal/config/config_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `internal/config/config_test.go`: + +```go +func TestProfileYAMLRoundTripWithDirectories(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{ + User: UserConfig{Name: "Andre", Email: "a@work.com"}, + Directories: []string{"/Users/andre/projects/work", "/Users/andre/Mollie"}, + } + + if err := cfg.SaveConfig(configFile); err != nil { + t.Fatalf("SaveConfig failed: %v", err) + } + + loaded, err := LoadConfig(configFile) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + got := loaded.Profiles["work"].Directories + want := []string{"/Users/andre/projects/work", "/Users/andre/Mollie"} + + if len(got) != len(want) { + t.Fatalf("Directories length: got %d, want %d", len(got), len(want)) + } + + for i := range want { + if got[i] != want[i] { + t.Errorf("Directories[%d]: got %q, want %q", i, got[i], want[i]) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run TestProfileYAMLRoundTripWithDirectories -v` +Expected: FAIL — `Profile` has no `Directories` field. + +- [ ] **Step 3: Add the field** + +In `internal/config/config.go`, add to `Profile` struct (insert before `URL`): + +```go + Directories []string `yaml:"directories,omitempty"` + URL []URLConfig `yaml:"url,omitempty"` +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/config/ -run TestProfileYAMLRoundTripWithDirectories -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/config.go internal/config/config_test.go +git commit --signoff --gpg-sign -m "feat(config): add Directories field to Profile" +``` + +--- + +## Task 2: Path normalization helper + +Goal: a single function that turns user input (`~/work`, `./foo`, `/abs/path`, `~/work/**`) into the form we'll embed in `gitdir:` directives. + +**Files:** +- Create: `internal/config/dirs.go` +- Create: `internal/config/dirs_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/config/dirs_test.go`: + +```go +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNormalizeDir(t *testing.T) { + t.Parallel() + + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("UserHomeDir failed: %v", err) + } + + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd failed: %v", err) + } + + tests := []struct { + name string + in string + want string + }{ + {"absolute path gets trailing slash", "/Users/x/projects/work", "/Users/x/projects/work/"}, + {"absolute path keeps existing trailing slash", "/Users/x/projects/work/", "/Users/x/projects/work/"}, + {"tilde expands to home", "~/projects/work", filepath.Join(home, "projects", "work") + "/"}, + {"relative resolves against cwd", "./foo", filepath.Join(cwd, "foo") + "/"}, + {"single-star glob passes through unchanged", "~/work/*/repo", "~/work/*/repo"}, + {"double-star glob passes through unchanged", "~/work/**", "~/work/**"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := NormalizeDir(tc.in) + if err != nil { + t.Fatalf("NormalizeDir(%q) error: %v", tc.in, err) + } + + if got != tc.want { + t.Errorf("NormalizeDir(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestNormalizeDirEmpty(t *testing.T) { + t.Parallel() + + if _, err := NormalizeDir(""); err == nil { + t.Error("expected error for empty path, got nil") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run TestNormalizeDir -v` +Expected: FAIL — `NormalizeDir` is undefined. + +- [ ] **Step 3: Implement `NormalizeDir`** + +Create `internal/config/dirs.go`: + +```go +package config + +import ( + "os" + "path/filepath" + "strings" + + "github.com/cockroachdb/errors" +) + +// NormalizeDir prepares a user-supplied directory path for use in a +// `gitdir:` includeIf directive. +// +// - Empty input is rejected. +// - Inputs containing `*` are passed through unchanged (treated as a +// git-style glob). +// - `~` is expanded to the user's home directory. +// - Relative paths are resolved against the current working directory. +// - A trailing slash is always appended so the directive matches the +// whole subtree, not just the directory itself. +func NormalizeDir(path string) (string, error) { + if path == "" { + return "", errors.New("directory path is empty") + } + + if strings.Contains(path, "*") { + return path, nil + } + + if strings.HasPrefix(path, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "", errors.Wrap(err, "failed to get user home directory") + } + + path = filepath.Join(home, strings.TrimPrefix(path, "~")) + } + + if !filepath.IsAbs(path) { + abs, err := filepath.Abs(path) + if err != nil { + return "", errors.Wrap(err, "failed to resolve relative path") + } + + path = abs + } + + if !strings.HasSuffix(path, "/") { + path += "/" + } + + return path, nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/config/ -run TestNormalizeDir -v` +Expected: PASS for all cases including `TestNormalizeDirEmpty`. + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/dirs.go internal/config/dirs_test.go +git commit --signoff --gpg-sign -m "feat(config): add NormalizeDir for includeIf path handling" +``` + +--- + +## Task 3: Reverse lookup and assignment map + +Goal: given a normalized path, find which profile owns it; and produce a flat `path → profile` map for emitting `[includeIf]` blocks. + +**Files:** +- Modify: `internal/config/dirs.go` +- Modify: `internal/config/dirs_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `internal/config/dirs_test.go`: + +```go +func TestLookupDir(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{ + Directories: []string{"/Users/x/work/", "/Users/x/Mollie/"}, + } + cfg.Profiles["personal"] = &Profile{ + Directories: []string{"/Users/x/personal/"}, + } + + if got, ok := cfg.LookupDir("/Users/x/work/"); !ok || got != "work" { + t.Errorf("LookupDir(work) = (%q, %v), want (\"work\", true)", got, ok) + } + + if got, ok := cfg.LookupDir("/Users/x/personal/"); !ok || got != "personal" { + t.Errorf("LookupDir(personal) = (%q, %v), want (\"personal\", true)", got, ok) + } + + if _, ok := cfg.LookupDir("/Users/x/none/"); ok { + t.Error("LookupDir(none) ok = true, want false") + } +} + +func TestAssignmentMap(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{ + Directories: []string{"/Users/x/work/", "/Users/x/Mollie/"}, + } + cfg.Profiles["personal"] = &Profile{ + Directories: []string{"/Users/x/personal/"}, + } + + got := cfg.AssignmentMap() + + if len(got) != 3 { + t.Fatalf("AssignmentMap len = %d, want 3", len(got)) + } + + wants := map[string]string{ + "/Users/x/work/": "work", + "/Users/x/Mollie/": "work", + "/Users/x/personal/": "personal", + } + + for path, wantProfile := range wants { + if got[path] != wantProfile { + t.Errorf("AssignmentMap[%q] = %q, want %q", path, got[path], wantProfile) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run "TestLookupDir|TestAssignmentMap" -v` +Expected: FAIL — methods undefined. + +- [ ] **Step 3: Implement `LookupDir` and `AssignmentMap`** + +Append to `internal/config/dirs.go`: + +```go +// LookupDir returns the profile name that owns the given directory path, +// or ("", false) if no profile claims it. The path must already be in its +// normalized form. +func (c *Config) LookupDir(path string) (string, bool) { + for name, profile := range c.Profiles { + for _, dir := range profile.Directories { + if dir == path { + return name, true + } + } + } + + return "", false +} + +// AssignmentMap returns a flat path-to-profile map across all profiles. +// Used to emit the [includeIf] block list in deterministic order. +func (c *Config) AssignmentMap() map[string]string { + out := make(map[string]string) + + for name, profile := range c.Profiles { + for _, dir := range profile.Directories { + out[dir] = name + } + } + + return out +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/config/ -run "TestLookupDir|TestAssignmentMap" -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/dirs.go internal/config/dirs_test.go +git commit --signoff --gpg-sign -m "feat(config): add LookupDir and AssignmentMap" +``` + +--- + +## Task 4: Assign / unassign with conflict detection + +Goal: a single entry point for adding and removing directory assignments that enforces "one profile per directory". + +**Files:** +- Modify: `internal/config/dirs.go` +- Modify: `internal/config/dirs_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `internal/config/dirs_test.go`: + +```go +func TestAssignDirSuccess(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{User: UserConfig{Name: "X"}} + + if err := cfg.AssignDir("/Users/x/work/", "work"); err != nil { + t.Fatalf("AssignDir error: %v", err) + } + + if got := cfg.Profiles["work"].Directories; len(got) != 1 || got[0] != "/Users/x/work/" { + t.Errorf("Directories = %v, want [/Users/x/work/]", got) + } +} + +func TestAssignDirRejectsUnknownProfile(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + + if err := cfg.AssignDir("/Users/x/work/", "ghost"); err == nil { + t.Error("expected error for unknown profile, got nil") + } +} + +func TestAssignDirRejectsDuplicateAcrossProfiles(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{Directories: []string{"/Users/x/shared/"}} + cfg.Profiles["personal"] = &Profile{} + + err := cfg.AssignDir("/Users/x/shared/", "personal") + if err == nil { + t.Fatal("expected error for duplicate path, got nil") + } + + if !strings.Contains(err.Error(), "already assigned") { + t.Errorf("error = %q, want it to mention 'already assigned'", err.Error()) + } +} + +func TestAssignDirIdempotentSameProfile(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{Directories: []string{"/Users/x/work/"}} + + if err := cfg.AssignDir("/Users/x/work/", "work"); err != nil { + t.Fatalf("AssignDir error: %v", err) + } + + if got := len(cfg.Profiles["work"].Directories); got != 1 { + t.Errorf("Directories len = %d, want 1 (no duplication)", got) + } +} + +func TestUnassignDirSuccess(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{ + Directories: []string{"/Users/x/work/", "/Users/x/Mollie/"}, + } + + if err := cfg.UnassignDir("/Users/x/work/"); err != nil { + t.Fatalf("UnassignDir error: %v", err) + } + + got := cfg.Profiles["work"].Directories + if len(got) != 1 || got[0] != "/Users/x/Mollie/" { + t.Errorf("Directories = %v, want [/Users/x/Mollie/]", got) + } +} + +func TestUnassignDirNotFound(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + + if err := cfg.UnassignDir("/Users/x/missing/"); err == nil { + t.Error("expected error for missing path, got nil") + } +} +``` + +Add `"strings"` to the imports of `dirs_test.go` if not already present. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run "TestAssignDir|TestUnassignDir" -v` +Expected: FAIL — `AssignDir`/`UnassignDir` undefined. + +- [ ] **Step 3: Implement assignment helpers** + +Append to `internal/config/dirs.go`: + +```go +// AssignDir adds `path` to the named profile's Directories list. +// Returns an error if: +// - the profile does not exist, or +// - the path is already assigned to a different profile. +// +// Re-assigning the same path to its current profile is a no-op. +func (c *Config) AssignDir(path, profileName string) error { + profile, exists := c.Profiles[profileName] + if !exists { + return errors.Newf("profile %q does not exist", profileName) + } + + if owner, ok := c.LookupDir(path); ok { + if owner == profileName { + return nil + } + + return errors.Newf( + "path %q is already assigned to profile %q; run 'dir remove' first", + path, owner, + ) + } + + profile.Directories = append(profile.Directories, path) + + return nil +} + +// UnassignDir removes `path` from whichever profile owns it. +// Returns an error if no profile owns the path. +func (c *Config) UnassignDir(path string) error { + owner, ok := c.LookupDir(path) + if !ok { + return errors.Newf("path %q is not assigned to any profile", path) + } + + profile := c.Profiles[owner] + filtered := profile.Directories[:0] + + for _, d := range profile.Directories { + if d != path { + filtered = append(filtered, d) + } + } + + profile.Directories = filtered + + return nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/config/ -run "TestAssignDir|TestUnassignDir" -v` +Expected: PASS for all cases. + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/dirs.go internal/config/dirs_test.go +git commit --signoff --gpg-sign -m "feat(config): add AssignDir/UnassignDir with conflict detection" +``` + +--- + +## Task 5: Add `ProfilesDir` to `Paths` + +**Files:** +- Modify: `internal/config/paths.go` +- Modify: `internal/config/paths_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `internal/config/paths_test.go`: + +```go +func TestPathsHasProfilesDir(t *testing.T) { + t.Parallel() + + paths, err := NewPaths() + if err != nil { + t.Fatalf("NewPaths failed: %v", err) + } + + if paths.ProfilesDir == "" { + t.Fatal("ProfilesDir is empty") + } + + want := filepath.Join(paths.ConfigDir, "profiles") + if paths.ProfilesDir != want { + t.Errorf("ProfilesDir = %q, want %q", paths.ProfilesDir, want) + } + + if _, err := os.Stat(paths.ProfilesDir); err != nil { + t.Errorf("ProfilesDir was not created: %v", err) + } +} +``` + +If `os` is not imported in `paths_test.go`, add it. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run TestPathsHasProfilesDir -v` +Expected: FAIL — `ProfilesDir` field does not exist. + +- [ ] **Step 3: Add the field and ensure the directory exists** + +In `internal/config/paths.go`, replace the `Paths` struct and `NewPaths`: + +```go +// Paths contains all the important path locations for git-context. +type Paths struct { + ConfigDir string + ConfigFile string + ProfilesDir string + GitConfigFile string + GitConfigBackup string +} + +// NewPaths initializes and creates paths with proper defaults. +func NewPaths() (*Paths, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, errors.Wrap(err, "failed to get user home directory") + } + + configDir := filepath.Join(home, ".config", "git-context") + configFile := filepath.Join(configDir, "config.yaml") + profilesDir := filepath.Join(configDir, "profiles") + gitConfigFile := filepath.Join(home, ".gitconfig") + gitConfigBackup := filepath.Join(home, ".gitconfig.bak") + + if err := os.MkdirAll(configDir, 0o755); err != nil { + return nil, errors.Wrap(err, "failed to create config directory") + } + + if err := os.MkdirAll(profilesDir, 0o755); err != nil { + return nil, errors.Wrap(err, "failed to create profiles directory") + } + + return &Paths{ + ConfigDir: configDir, + ConfigFile: configFile, + ProfilesDir: profilesDir, + GitConfigFile: gitConfigFile, + GitConfigBackup: gitConfigBackup, + }, nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/config/ -run TestPathsHasProfilesDir -v` +Expected: PASS. + +Also re-run the full config tests to confirm nothing else broke: + +Run: `go test ./internal/config/ -v` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/paths.go internal/config/paths_test.go +git commit --signoff --gpg-sign -m "feat(config): add ProfilesDir to Paths" +``` + +--- + +## Task 6: `WriteProfileFile` — atomic write of one profile's gitconfig + +Goal: a function that takes a target path and a `map[string]any` of git settings, and writes it atomically (temp file + rename). Reuses the existing `buildGitConfig` formatter. + +**Files:** +- Modify: `internal/git/git.go` +- Modify: `internal/git/git_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `internal/git/git_test.go`: + +```go +func TestWriteProfileFile(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "work.gitconfig") + + g := NewGit(filepath.Join(tmpDir, ".gitconfig")) + + settings := map[string]any{ + "user.name": "Andre", + "user.email": "andre@work.com", + } + + if err := g.WriteProfileFile(target, settings); err != nil { + t.Fatalf("WriteProfileFile error: %v", err) + } + + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + + content := string(data) + + if !strings.Contains(content, "[user]") { + t.Errorf("missing [user] section in:\n%s", content) + } + + if !strings.Contains(content, "name = Andre") { + t.Errorf("missing user.name in:\n%s", content) + } + + if !strings.Contains(content, "email = andre@work.com") { + t.Errorf("missing user.email in:\n%s", content) + } +} + +func TestWriteProfileFileAtomic(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "work.gitconfig") + + // Pre-create the file with old content to verify replace, not append. + if err := os.WriteFile(target, []byte("OLD\n"), 0o644); err != nil { + t.Fatalf("setup write failed: %v", err) + } + + g := NewGit(filepath.Join(tmpDir, ".gitconfig")) + + if err := g.WriteProfileFile(target, map[string]any{"user.name": "New"}); err != nil { + t.Fatalf("WriteProfileFile error: %v", err) + } + + data, _ := os.ReadFile(target) + if strings.Contains(string(data), "OLD") { + t.Errorf("old content not replaced:\n%s", data) + } + + // No leftover .tmp file. + matches, _ := filepath.Glob(filepath.Join(tmpDir, "*.tmp")) + if len(matches) > 0 { + t.Errorf("temp files left behind: %v", matches) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/git/ -run TestWriteProfileFile -v` +Expected: FAIL — `WriteProfileFile` undefined. + +- [ ] **Step 3: Implement `WriteProfileFile`** + +In `internal/git/git.go`, append: + +```go +// WriteProfileFile writes a flat key→value git config map to `path` +// using atomic temp-file-and-rename semantics. +func (g *Git) WriteProfileFile(path string, config map[string]any) error { + content := buildGitConfig(config) + + return atomicWrite(path, []byte(content)) +} + +// atomicWrite writes data to a sibling `.tmp` file then renames it into place. +func atomicWrite(path string, data []byte) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return errors.Wrap(err, "failed to write temp file") + } + + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return errors.Wrap(err, "failed to rename temp file into place") + } + + return nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/git/ -run TestWriteProfileFile -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/git/git.go internal/git/git_test.go +git commit --signoff --gpg-sign -m "feat(git): add atomic WriteProfileFile" +``` + +--- + +## Task 7: `WriteRootConfig` — generate the `~/.gitconfig` manifest + +Goal: write a manifest with a header marker, an unconditional `[include]` for the default profile (if any), and one `[includeIf]` block per assignment. Block order must be deterministic (sort by `gitdir` path string). + +**Files:** +- Modify: `internal/git/git.go` +- Modify: `internal/git/git_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `internal/git/git_test.go`: + +```go +func TestWriteRootConfigDefaultAndAssignments(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, ".gitconfig") + g := NewGit(rootPath) + + defaultProfilePath := filepath.Join(tmpDir, "profiles", "work.gitconfig") + assignments := map[string]string{ + "/Users/x/projects/personal/": filepath.Join(tmpDir, "profiles", "personal.gitconfig"), + "/Users/x/Mollie/": filepath.Join(tmpDir, "profiles", "work.gitconfig"), + } + + if err := g.WriteRootConfig(defaultProfilePath, assignments); err != nil { + t.Fatalf("WriteRootConfig error: %v", err) + } + + data, err := os.ReadFile(rootPath) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + + content := string(data) + + if !strings.Contains(content, "Generated by git-context") { + t.Errorf("missing header marker:\n%s", content) + } + + if !strings.Contains(content, "[include]") { + t.Errorf("missing [include] block:\n%s", content) + } + + if !strings.Contains(content, "path = "+defaultProfilePath) { + t.Errorf("missing default profile path:\n%s", content) + } + + if !strings.Contains(content, `[includeIf "gitdir:/Users/x/Mollie/"]`) { + t.Errorf("missing Mollie includeIf:\n%s", content) + } + + if !strings.Contains(content, `[includeIf "gitdir:/Users/x/projects/personal/"]`) { + t.Errorf("missing personal includeIf:\n%s", content) + } +} + +func TestWriteRootConfigDeterministicOrder(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, ".gitconfig") + g := NewGit(rootPath) + + assignments := map[string]string{ + "/zzz/": "/profiles/c.gitconfig", + "/aaa/": "/profiles/a.gitconfig", + "/mmm/": "/profiles/b.gitconfig", + } + + if err := g.WriteRootConfig("", assignments); err != nil { + t.Fatalf("WriteRootConfig error: %v", err) + } + + data, _ := os.ReadFile(rootPath) + content := string(data) + + idxA := strings.Index(content, "gitdir:/aaa/") + idxM := strings.Index(content, "gitdir:/mmm/") + idxZ := strings.Index(content, "gitdir:/zzz/") + + if !(idxA < idxM && idxM < idxZ) { + t.Errorf("includeIf blocks not sorted: aaa=%d mmm=%d zzz=%d", idxA, idxM, idxZ) + } +} + +func TestWriteRootConfigNoDefaultNoAssignments(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, ".gitconfig") + g := NewGit(rootPath) + + if err := g.WriteRootConfig("", map[string]string{}); err != nil { + t.Fatalf("WriteRootConfig error: %v", err) + } + + if _, err := os.Stat(rootPath); !os.IsNotExist(err) { + t.Errorf("expected no file written, got err=%v", err) + } +} + +func TestWriteRootConfigParseableByGit(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, ".gitconfig") + g := NewGit(rootPath) + + defaultPath := filepath.Join(tmpDir, "default.gitconfig") + assignments := map[string]string{"/Users/x/work/": filepath.Join(tmpDir, "work.gitconfig")} + + if err := g.WriteRootConfig(defaultPath, assignments); err != nil { + t.Fatalf("WriteRootConfig error: %v", err) + } + + cmd := exec.Command("git", "config", "-f", rootPath, "--list") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git config rejected file: %v\n%s", err, out) + } +} +``` + +Add `"os/exec"` to the imports of `git_test.go` if not already present. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/git/ -run TestWriteRootConfig -v` +Expected: FAIL — `WriteRootConfig` undefined. + +- [ ] **Step 3: Implement `WriteRootConfig`** + +Append to `internal/git/git.go`: + +```go +// rootConfigHeader is emitted at the top of the generated ~/.gitconfig. +// Used both to inform users that the file is managed and (optionally in +// the future) to detect prior generations. +const rootConfigHeader = "# Generated by git-context — do not edit. " + + "Run `git-context` to manage.\n" + +// WriteRootConfig generates the ~/.gitconfig manifest: +// - a header marker +// - one unconditional [include] block for `defaultProfilePath`, if set +// - one [includeIf "gitdir:"] block per (path → profile-file) entry +// +// `assignments` keys are normalized directory paths (with trailing `/`), +// values are absolute paths to per-profile gitconfig files. Blocks are +// emitted in sorted key order so output is deterministic. +// +// If the default is empty AND assignments is empty, no file is written +// (avoids clobbering an existing user-managed ~/.gitconfig before any +// git-context state exists). +func (g *Git) WriteRootConfig(defaultProfilePath string, assignments map[string]string) error { + if defaultProfilePath == "" && len(assignments) == 0 { + return nil + } + + var b strings.Builder + + b.WriteString(rootConfigHeader) + b.WriteString("\n") + + if defaultProfilePath != "" { + fmt.Fprintf(&b, "[include]\n\tpath = %s\n\n", defaultProfilePath) + } + + keys := make([]string, 0, len(assignments)) + for k := range assignments { + keys = append(keys, k) + } + + sort.Strings(keys) + + for _, dir := range keys { + fmt.Fprintf(&b, "[includeIf \"gitdir:%s\"]\n\tpath = %s\n\n", dir, assignments[dir]) + } + + return atomicWrite(g.globalConfigPath, []byte(b.String())) +} +``` + +Add `"sort"` to the imports of `git.go` if not present (`fmt`, `os`, `strings` already are). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/git/ -run TestWriteRootConfig -v` +Expected: all PASS, including the `git config -f --list` round-trip. + +- [ ] **Step 5: Commit** + +```bash +git add internal/git/git.go internal/git/git_test.go +git commit --signoff --gpg-sign -m "feat(git): add WriteRootConfig manifest writer" +``` + +--- + +## Task 8: `Regenerate` — orchestrator + +Goal: one entry point that mutating commands call. It writes every profile to its file and then writes the root manifest. + +**Files:** +- Modify: `internal/git/git.go` +- Modify: `internal/git/git_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `internal/git/git_test.go`: + +```go +func TestRegenerate(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + profilesDir := filepath.Join(tmpDir, "profiles") + + if err := os.MkdirAll(profilesDir, 0o755); err != nil { + t.Fatalf("mkdir profiles: %v", err) + } + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Name: "Andre", Email: "a@work.com"}, + Directories: []string{"/Users/x/Mollie/"}, + } + cfg.Profiles["personal"] = &config.Profile{ + User: config.UserConfig{Name: "Andre", Email: "a@home.com"}, + Directories: []string{"/Users/x/projects/personal/"}, + } + cfg.Current = "work" + + rootPath := filepath.Join(tmpDir, ".gitconfig") + g := NewGit(rootPath) + + if err := g.Regenerate(cfg, profilesDir); err != nil { + t.Fatalf("Regenerate error: %v", err) + } + + // Per-profile files exist with expected content. + for name, wantEmail := range map[string]string{"work": "a@work.com", "personal": "a@home.com"} { + path := filepath.Join(profilesDir, name+".gitconfig") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + + if !strings.Contains(string(data), "email = "+wantEmail) { + t.Errorf("%s missing email=%s\n%s", name, wantEmail, data) + } + } + + // Root manifest references the right files. + root, err := os.ReadFile(rootPath) + if err != nil { + t.Fatalf("read root: %v", err) + } + + rootStr := string(root) + wantInclude := filepath.Join(profilesDir, "work.gitconfig") + + if !strings.Contains(rootStr, "path = "+wantInclude) { + t.Errorf("root missing default include for %q:\n%s", wantInclude, rootStr) + } + + if !strings.Contains(rootStr, `gitdir:/Users/x/Mollie/`) { + t.Errorf("root missing Mollie includeIf:\n%s", rootStr) + } + + if !strings.Contains(rootStr, `gitdir:/Users/x/projects/personal/`) { + t.Errorf("root missing personal includeIf:\n%s", rootStr) + } +} + +func TestRegenerateNoOpWhenNothingToWrite(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, ".gitconfig") + profilesDir := filepath.Join(tmpDir, "profiles") + + if err := os.MkdirAll(profilesDir, 0o755); err != nil { + t.Fatalf("mkdir profiles: %v", err) + } + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{User: config.UserConfig{Name: "X"}} + // No Current, no Directories — nothing to write to root manifest. + + g := NewGit(rootPath) + + if err := g.Regenerate(cfg, profilesDir); err != nil { + t.Fatalf("Regenerate error: %v", err) + } + + if _, err := os.Stat(rootPath); !os.IsNotExist(err) { + t.Errorf("expected no root manifest, got err=%v", err) + } + + // Per-profile file still gets written, regardless of whether root manifest is. + if _, err := os.Stat(filepath.Join(profilesDir, "work.gitconfig")); err != nil { + t.Errorf("expected work.gitconfig to exist: %v", err) + } +} +``` + +Add `"github.com/aanogueira/git-context/internal/config"` to the imports of `git_test.go`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/git/ -run TestRegenerate -v` +Expected: FAIL — `Regenerate` undefined; also `cfg.Merge` not used directly so no related compile errors anticipated. + +- [ ] **Step 3: Implement `Regenerate`** + +Append to `internal/git/git.go`: + +```go +// Regenerate writes one gitconfig file per profile under `profilesDir`, +// then writes the root manifest at `g.globalConfigPath`. Called by every +// mutating command (`switch`, `add` with dirs, `remove`, `dir add/remove`). +// +// Per-profile files use the merged (global + profile) configuration so they +// behave identically to the file `switch` used to write inline. +// +// The root manifest is written by WriteRootConfig and may be a no-op if no +// default profile is set and no directories are assigned. +func (g *Git) Regenerate(cfg *config.Config, profilesDir string) error { + for name := range cfg.Profiles { + merged, err := cfg.Merge(name) + if err != nil { + return errors.Wrapf(err, "failed to merge profile %q", name) + } + + path := filepath.Join(profilesDir, name+".gitconfig") + if err := g.WriteProfileFile(path, profileToFlatConfig(merged)); err != nil { + return errors.Wrapf(err, "failed to write profile file for %q", name) + } + } + + defaultPath := "" + if cfg.Current != "" { + defaultPath = filepath.Join(profilesDir, cfg.Current+".gitconfig") + } + + assignments := make(map[string]string) + for path, profileName := range cfg.AssignmentMap() { + assignments[path] = filepath.Join(profilesDir, profileName+".gitconfig") + } + + return g.WriteRootConfig(defaultPath, assignments) +} + +// profileToFlatConfig is the same converter that `cmd.profileToGitConfig` +// uses. Keep them in sync; we host it in the git package because Regenerate +// needs it without dragging in cmd. +func profileToFlatConfig(profile *config.Profile) map[string]any { + out := make(map[string]any) + + if profile.User.Name != "" { + out["user.name"] = profile.User.Name + } + + if profile.User.Email != "" { + out["user.email"] = profile.User.Email + } + + if profile.User.SigningKey != "" { + out["user.signingkey"] = profile.User.SigningKey + } + + for _, url := range profile.URL { + key := fmt.Sprintf("url \"%s\".insteadOf", url.Pattern) + out[key] = url.InsteadOf + } + + for _, section := range config.ConfigSections { + if sectionMap := profile.GetSection(section); sectionMap != nil { + flattenInto(out, section, sectionMap) + } + } + + return out +} + +// flattenInto walks a nested map and writes leaf values keyed by dotted path. +func flattenInto(out map[string]any, prefix string, values map[string]any) { + for k, v := range values { + key := prefix + "." + k + if m, ok := v.(map[string]any); ok { + flattenInto(out, key, m) + } else { + out[key] = v + } + } +} +``` + +Add `"path/filepath"` and `"github.com/aanogueira/git-context/internal/config"` to the imports of `git.go`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/git/ -v` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/git/git.go internal/git/git_test.go +git commit --signoff --gpg-sign -m "feat(git): add Regenerate orchestrator" +``` + +--- + +## Task 9: Switch over `cmd/switch.go` to use `Regenerate` + +Goal: keep `switch` behavior unchanged from the user's perspective — it sets the default profile — but write through `Regenerate` so the new file layout takes over. Drop the now-redundant inline `profileToGitConfig` helper from `cmd/switch.go`. + +**Files:** +- Modify: `cmd/switch.go` +- Test: existing `cmd/cmd_test.go` + +- [ ] **Step 1: Update `cmd/switch.go`** + +Replace the body of `runSwitch` between the "Build the merged configuration" comment and the `cfg.Current = profileName` line with the regenerate call. Specifically, replace lines 69-83 of `cmd/switch.go` with: + +```go + g := git.NewGit(paths.GitConfigFile) + + if err := g.BackupConfig(paths.GitConfigBackup); err != nil { + ui.PrintWarning(fmt.Sprintf("Failed to backup git config: %v", err)) + } else { + ui.PrintInfo("Backed up git config to " + paths.GitConfigBackup) + } + + // Update current profile bookkeeping before regenerate so the new + // default is reflected in the manifest. + if cfg.Current != "" && cfg.Current != profileName { + cfg.Previous = cfg.Current + } + + cfg.Current = profileName + + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + ui.PrintError(fmt.Sprintf("Failed to regenerate git config: %v", err)) + + return errors.Wrap(err, "failed to regenerate git config") + } +``` + +Then delete the `cfg.Current = profileName` line that previously came after `WriteConfig`, plus the now-orphaned `profileToGitConfig` / `addSectionToConfig` / `addSectionToConfigRecursive` helpers at the bottom of the file (lines 103-166). The git package owns flattening now. + +The `g := git.NewGit(...)` and backup blocks already exist at lines 60-67; do not duplicate them. Delete the original lines 60-95 entirely and replace with the block above. The `if err := cfg.SaveConfig(...)` block at lines 91-95 stays (move it to immediately after the `Regenerate` call). + +After editing, the body of `runSwitch` from "Create Git instance" downward should read: + +```go + g := git.NewGit(paths.GitConfigFile) + + if err := g.BackupConfig(paths.GitConfigBackup); err != nil { + ui.PrintWarning(fmt.Sprintf("Failed to backup git config: %v", err)) + } else { + ui.PrintInfo("Backed up git config to " + paths.GitConfigBackup) + } + + if cfg.Current != "" && cfg.Current != profileName { + cfg.Previous = cfg.Current + } + + cfg.Current = profileName + + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + ui.PrintError(fmt.Sprintf("Failed to regenerate git config: %v", err)) + + return errors.Wrap(err, "failed to regenerate git config") + } + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + ui.PrintError(fmt.Sprintf("Failed to save config: %v", err)) + + return errors.Wrap(err, "failed to save config") + } + + ui.PrintSuccess(fmt.Sprintf("Switched to profile '%s'", profileName)) + ui.PrintInfo(fmt.Sprintf("User: %s <%s>", profile.User.Name, profile.User.Email)) + + return nil +``` + +Delete the trailing helpers (`profileToGitConfig`, `addSectionToConfig`, `addSectionToConfigRecursive`). + +- [ ] **Step 2: Build to verify it compiles** + +Run: `go build ./...` +Expected: success. + +- [ ] **Step 3: Run the full cmd test suite** + +Run: `go test ./cmd/ -v` +Expected: existing switch tests pass — `Regenerate` produces a `~/.gitconfig` whose effect on `git config user.email` is identical to before for a single-profile, no-dirs case (the "no assignments" path of `WriteRootConfig` writes only the `[include]` block, which git then reads from the per-profile file). + +If a test was reading `~/.gitconfig` directly to verify content, update it to read the per-profile file at `paths.ProfilesDir/.gitconfig` instead. Inspect any failures and adjust assertions in `cmd/cmd_test.go` accordingly. + +- [ ] **Step 4: Commit** + +```bash +git add cmd/switch.go cmd/cmd_test.go +git commit --signoff --gpg-sign -m "refactor(switch): write via Regenerate, emit manifest" +``` + +--- + +## Task 10: `remove` confirmation when profile has directories + +**Files:** +- Modify: `cmd/remove.go` +- Modify: `cmd/cmd_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `cmd/cmd_test.go`: + +```go +func TestRemoveRegeneratesAndDropsDirectories(t *testing.T) { + t.Parallel() + + // Set up a tmp HOME so paths point at a sandbox. + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, err := config.NewPaths() + if err != nil { + t.Fatalf("NewPaths: %v", err) + } + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Name: "X", Email: "x@work"}, + Directories: []string{"/tmp/work/"}, + } + cfg.Current = "work" + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + // Pre-generate so a stale work.gitconfig exists. + g := git.NewGit(paths.GitConfigFile) + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + t.Fatalf("Regenerate: %v", err) + } + + // Remove the profile (auto-confirm via removeProfileForTest helper below). + if err := removeProfileForTest(paths, "work"); err != nil { + t.Fatalf("removeProfileForTest: %v", err) + } + + // Reload and verify the profile + its directories are gone. + loaded, err := config.LoadConfig(paths.ConfigFile) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + if _, exists := loaded.Profiles["work"]; exists { + t.Error("profile 'work' still present after remove") + } + + // Root manifest should no longer reference Mollie or work.gitconfig. + if _, err := os.Stat(paths.GitConfigFile); err == nil { + data, _ := os.ReadFile(paths.GitConfigFile) + if strings.Contains(string(data), "/tmp/work/") { + t.Errorf("root manifest still references removed dir:\n%s", data) + } + } +} +``` + +`removeProfileForTest` is a small helper that mirrors `runRemove` but skips the interactive confirmation. Add it to `cmd/cmd_test.go`: + +```go +// removeProfileForTest performs the same regeneration logic as runRemove +// but without the interactive prompt — used so tests can exercise the +// post-confirmation code path. +func removeProfileForTest(paths *config.Paths, name string) error { + cfg, err := config.LoadConfig(paths.ConfigFile) + if err != nil { + return err + } + + if err := cfg.RemoveProfile(name); err != nil { + return err + } + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + return err + } + + return git.NewGit(paths.GitConfigFile).Regenerate(cfg, paths.ProfilesDir) +} +``` + +Add the necessary imports (`os`, `strings`, `github.com/aanogueira/git-context/internal/git`) at the top of `cmd_test.go` if not already there. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./cmd/ -run TestRemoveRegeneratesAndDropsDirectories -v` +Expected: FAIL — `runRemove` does not yet call `Regenerate`, so the manifest still contains the assignment. + +- [ ] **Step 3: Update `cmd/remove.go`** + +Replace the body of `runRemove` after `cfg.GetProfile` succeeds with: + +```go + hasDirs := len(cfg.Profiles[profileName].Directories) > 0 + + prompt := fmt.Sprintf("Are you sure you want to remove profile '%s'?", profileName) + if hasDirs { + prompt = fmt.Sprintf( + "Profile '%s' has %d assigned director%s. Remove anyway?", + profileName, + len(cfg.Profiles[profileName].Directories), + plural(len(cfg.Profiles[profileName].Directories), "y", "ies"), + ) + } + + confirm, err := ui.PromptConfirm(prompt) + if err != nil { + ui.PrintWarning("Removal canceled") + + return errors.Wrap(err, "failed to confirm removal") + } + + if !confirm { + ui.PrintWarning("Removal canceled") + + return nil + } + + if err := cfg.RemoveProfile(profileName); err != nil { + ui.PrintError(fmt.Sprintf("Failed to remove profile: %v", err)) + + return errors.Wrap(err, "failed to remove profile") + } + + // If the removed profile was the current default, clear it. + if cfg.Current == profileName { + cfg.Current = "" + } + + if cfg.Previous == profileName { + cfg.Previous = "" + } + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + ui.PrintError(fmt.Sprintf("Failed to save config: %v", err)) + + return errors.Wrap(err, "failed to save config") + } + + g := git.NewGit(paths.GitConfigFile) + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + ui.PrintError(fmt.Sprintf("Failed to regenerate git config: %v", err)) + + return errors.Wrap(err, "failed to regenerate git config") + } + + ui.PrintSuccess(fmt.Sprintf("Profile '%s' removed successfully", profileName)) + + return nil +``` + +Add the `plural` helper at the bottom of `remove.go`: + +```go +func plural(n int, one, many string) string { + if n == 1 { + return one + } + + return many +} +``` + +Add `"github.com/aanogueira/git-context/internal/git"` to the imports. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./cmd/ -v` +Expected: PASS, including the new test and all existing remove tests. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/remove.go cmd/cmd_test.go +git commit --signoff --gpg-sign -m "feat(remove): regenerate manifest, prompt mentions assigned dirs" +``` + +--- + +## Task 11: `list` shows a "Dirs" column + +**Files:** +- Modify: `cmd/list.go` +- Modify: `cmd/cmd_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `cmd/cmd_test.go`: + +```go +func TestListProfilesShowsDirsColumn(t *testing.T) { + t.Parallel() + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Email: "x@work"}, + Directories: []string{"/a/", "/b/"}, + } + cfg.Profiles["personal"] = &config.Profile{User: config.UserConfig{Email: "x@home"}} + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + out := captureStdout(t, func() { + if err := runList(nil, nil); err != nil { + t.Fatalf("runList: %v", err) + } + }) + + if !strings.Contains(out, "Dirs") { + t.Errorf("output missing Dirs header:\n%s", out) + } + + if !strings.Contains(out, "2") { + t.Errorf("output missing dir count of 2 for work:\n%s", out) + } +} +``` + +If `captureStdout` doesn't already exist in `cmd_test.go`, add it: + +```go +// captureStdout runs fn and returns whatever it wrote to os.Stdout. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + + old := os.Stdout + os.Stdout = w + + done := make(chan string) + + go func() { + var buf bytes.Buffer + + _, _ = buf.ReadFrom(r) + done <- buf.String() + }() + + fn() + + w.Close() + os.Stdout = old + + return <-done +} +``` + +Add `"bytes"` to the imports if needed. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./cmd/ -run TestListProfilesShowsDirsColumn -v` +Expected: FAIL — table headers don't include "Dirs". + +- [ ] **Step 3: Update `cmd/list.go`** + +Replace the rows construction and `PrintTable` call with: + +```go + rows := make([][]string, len(profiles)) + for i, profile := range profiles { + status := "" + if profile == cfg.Current { + status = "● (active)" + } + + p, _ := cfg.GetProfile(profile) + + email := "" + + dirs := "0" + + if p != nil { + if p.User.Email != "" { + email = p.User.Email + } + + dirs = fmt.Sprintf("%d", len(p.Directories)) + } + + rows[i] = []string{profile, email, dirs, status} + } + + ui.PrintTable([]string{"Profile", "Email", "Dirs", "Status"}, rows) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./cmd/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/list.go cmd/cmd_test.go +git commit --signoff --gpg-sign -m "feat(list): show assigned-directory count column" +``` + +--- + +## Task 12: `show` displays assigned directories + +**Files:** +- Modify: `cmd/show.go` +- Modify: `cmd/cmd_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `cmd/cmd_test.go`: + +```go +func TestShowDisplaysDirectories(t *testing.T) { + t.Parallel() + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Name: "X", Email: "x@work"}, + Directories: []string{"/Users/x/work/", "/Users/x/Mollie/"}, + } + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + out := captureStdout(t, func() { + if err := runShow(nil, []string{"work"}); err != nil { + t.Fatalf("runShow: %v", err) + } + }) + + if !strings.Contains(out, "Directories") { + t.Errorf("output missing Directories label:\n%s", out) + } + + if !strings.Contains(out, "/Users/x/work/") { + t.Errorf("output missing assigned dir:\n%s", out) + } + + if !strings.Contains(out, "/Users/x/Mollie/") { + t.Errorf("output missing assigned dir:\n%s", out) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./cmd/ -run TestShowDisplaysDirectories -v` +Expected: FAIL — `runShow` does not print directories. + +- [ ] **Step 3: Update `cmd/show.go`** + +Append, after the URL rewrites block: + +```go + if len(profile.Directories) > 0 { + fmt.Println() + ui.PrintInfo("Directories:") + + for _, dir := range profile.Directories { + ui.PrintInfo(" " + dir) + } + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./cmd/ -run TestShowDisplaysDirectories -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/show.go cmd/cmd_test.go +git commit --signoff --gpg-sign -m "feat(show): list assigned directories" +``` + +--- + +## Task 13: `current` shows the effective profile in `$PWD` + +Goal: after the existing "Current Profile" output, run `git config --show-origin user.email` from `$PWD` and, if the resolved file is one of our profile files, print which profile is effective here. + +**Files:** +- Modify: `cmd/current.go` +- Modify: `cmd/cmd_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `cmd/cmd_test.go`: + +```go +func TestCurrentShowsEffectiveProfileInCwd(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Name: "Andre", Email: "a@work.com"}, + Directories: []string{}, + } + cfg.Profiles["personal"] = &config.Profile{ + User: config.UserConfig{Name: "Andre", Email: "a@home.com"}, + Directories: []string{}, + } + cfg.Current = "work" + + // Create a fake repo dir and assign it to personal. + repoDir := filepath.Join(tmpHome, "personal-repo") + if err := os.MkdirAll(repoDir, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + + if out, err := exec.Command("git", "-C", repoDir, "init").CombinedOutput(); err != nil { + t.Fatalf("git init: %v\n%s", err, out) + } + + cfg.Profiles["personal"].Directories = []string{repoDir + "/"} + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + g := git.NewGit(paths.GitConfigFile) + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + t.Fatalf("Regenerate: %v", err) + } + + // Chdir into the assigned repo and check current. + oldDir, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(oldDir) }) + + if err := os.Chdir(repoDir); err != nil { + t.Fatalf("chdir: %v", err) + } + + out := captureStdout(t, func() { + if err := runCurrent(nil, nil); err != nil { + t.Fatalf("runCurrent: %v", err) + } + }) + + if !strings.Contains(out, "Effective in") { + t.Errorf("output missing 'Effective in' line:\n%s", out) + } + + if !strings.Contains(out, "personal") { + t.Errorf("expected 'personal' to be effective in this dir:\n%s", out) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./cmd/ -run TestCurrentShowsEffectiveProfileInCwd -v` +Expected: FAIL — `runCurrent` doesn't shell out to git. + +- [ ] **Step 3: Update `cmd/current.go`** + +Replace the file's contents with: + +```go +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/aanogueira/git-context/internal/config" + "github.com/aanogueira/git-context/internal/ui" + "github.com/cockroachdb/errors" + "github.com/spf13/cobra" +) + +var currentCmd = &cobra.Command{ + Use: "current", + Short: "Show the currently active profile", + Long: `Display which git configuration profile is currently active globally and effective in the current directory.`, + RunE: runCurrent, +} + +func runCurrent(cmd *cobra.Command, args []string) error { + paths, err := config.NewPaths() + if err != nil { + ui.PrintError(fmt.Sprintf("Failed to get paths: %v", err)) + + return errors.Wrap(err, "failed to get paths") + } + + cfg, err := config.LoadConfig(paths.ConfigFile) + if err != nil { + ui.PrintError(fmt.Sprintf("Failed to load config: %v", err)) + + return errors.Wrap(err, "failed to load config") + } + + ui.PrintHeader("Current Profile") + + if cfg.Current == "" { + ui.PrintWarning("No active profile set") + } else { + profile, err := cfg.GetProfile(cfg.Current) + if err != nil { + ui.PrintError(fmt.Sprintf("Active profile not found: %v", err)) + + return errors.Wrap(err, "failed to get active profile") + } + + ui.PrintInfo("Default: " + cfg.Current) + ui.PrintInfo("Name: " + profile.User.Name) + ui.PrintInfo("Email: " + profile.User.Email) + } + + if effective := effectiveProfileInCwd(paths, cfg); effective != "" { + ui.PrintInfo("Effective in " + currentDir() + ": " + effective) + } + + return nil +} + +// effectiveProfileInCwd asks git which file user.email comes from in the +// current working directory. If the answer is one of our per-profile files, +// returns the profile name; otherwise returns "". +func effectiveProfileInCwd(paths *config.Paths, cfg *config.Config) string { + cmd := exec.Command("git", "config", "--show-origin", "user.email") + + out, err := cmd.Output() + if err != nil { + return "" + } + + // Output format: "file:/path/to/source\tvalue" + line := strings.TrimSpace(string(out)) + + parts := strings.SplitN(line, "\t", 2) + if len(parts) == 0 { + return "" + } + + origin := strings.TrimPrefix(parts[0], "file:") + + for name := range cfg.Profiles { + profilePath := filepath.Join(paths.ProfilesDir, name+".gitconfig") + if origin == profilePath { + return name + } + } + + return "" +} + +func currentDir() string { + d, err := os.Getwd() + if err != nil { + return "." + } + + return d +} + +func init() { + rootCmd.AddCommand(currentCmd) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./cmd/ -run TestCurrent -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/current.go cmd/cmd_test.go +git commit --signoff --gpg-sign -m "feat(current): show effective profile in cwd" +``` + +--- + +## Task 14: Optional "assign directories" prompt in `add` + +Goal: after the URL-rewrites prompt, ask whether the user wants to assign directories to the new profile. If yes, accept paths in a loop, normalize them, and store them on the profile. Trigger `Regenerate` only when at least one directory was added. + +**Files:** +- Modify: `cmd/add.go` +- Modify: `cmd/cmd_test.go` + +- [ ] **Step 1: Update `cmd/add.go`** + +Right before `if err := cfg.AddProfile(profileName, profile); err != nil {`, add the directory prompt block: + +```go + addDirs, _ := ui.PromptConfirm("Assign directories to this profile?") + if addDirs { + for { + path, err := ui.PromptText("Directory path (leave empty to stop)", "") + if err != nil || path == "" { + break + } + + normalized, err := config.NormalizeDir(path) + if err != nil { + ui.PrintWarning(fmt.Sprintf("Skipping %q: %v", path, err)) + + continue + } + + profile.Directories = append(profile.Directories, normalized) + + more, _ := ui.PromptConfirm("Add another directory?") + if !more { + break + } + } + } +``` + +After the existing `cfg.SaveConfig(...)` call succeeds, add: + +```go + if len(profile.Directories) > 0 || cfg.Current != "" { + g := git.NewGit(paths.GitConfigFile) + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + ui.PrintError(fmt.Sprintf("Failed to regenerate git config: %v", err)) + + return errors.Wrap(err, "failed to regenerate git config") + } + } +``` + +Add `"github.com/aanogueira/git-context/internal/git"` to the imports. + +- [ ] **Step 2: Build to verify it compiles** + +Run: `go build ./...` +Expected: success. + +- [ ] **Step 3: Run existing add tests** + +Run: `go test ./cmd/ -run TestAdd -v` +Expected: existing tests still pass. Adjust assertions if a test was relying on the absence of the new prompt (the prompt is interactive — if the test stubs prompts, ensure the new one returns "No" by default). + +- [ ] **Step 4: Commit** + +```bash +git add cmd/add.go cmd/cmd_test.go +git commit --signoff --gpg-sign -m "feat(add): optional directory-assignment prompt" +``` + +--- + +## Task 15: New `dir` command (`add` / `remove` / `list`) + +Goal: the user-facing surface for managing directory assignments. + +**Files:** +- Create: `cmd/dir.go` +- Create: `cmd/dir_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `cmd/dir_test.go`: + +```go +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aanogueira/git-context/internal/config" +) + +func TestDirAddAssignsAndRegenerates(t *testing.T) { + t.Parallel() + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{User: config.UserConfig{Name: "X", Email: "x@work"}} + cfg.Current = "work" + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + if err := runDirAdd(nil, []string{"/tmp/myrepo", "work"}); err != nil { + t.Fatalf("runDirAdd: %v", err) + } + + loaded, _ := config.LoadConfig(paths.ConfigFile) + if got := loaded.Profiles["work"].Directories; len(got) != 1 || got[0] != "/tmp/myrepo/" { + t.Errorf("Directories = %v, want [/tmp/myrepo/]", got) + } + + root, err := os.ReadFile(paths.GitConfigFile) + if err != nil { + t.Fatalf("read root: %v", err) + } + + if !strings.Contains(string(root), `gitdir:/tmp/myrepo/`) { + t.Errorf("root manifest missing includeIf:\n%s", root) + } +} + +func TestDirAddRejectsDuplicate(t *testing.T) { + t.Parallel() + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{Directories: []string{"/tmp/x/"}} + cfg.Profiles["personal"] = &config.Profile{} + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + err := runDirAdd(nil, []string{"/tmp/x", "personal"}) + if err == nil { + t.Fatal("expected error for duplicate, got nil") + } + + if !strings.Contains(err.Error(), "already assigned") { + t.Errorf("error = %q, want it to mention 'already assigned'", err.Error()) + } +} + +func TestDirAddWarnsWhenNoDefaultProfile(t *testing.T) { + t.Parallel() + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{User: config.UserConfig{Name: "X"}} + // cfg.Current intentionally left empty. + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + out := captureStdout(t, func() { + if err := runDirAdd(nil, []string{"/tmp/x", "work"}); err != nil { + t.Fatalf("runDirAdd: %v", err) + } + }) + + if !strings.Contains(out, "no default profile set") { + t.Errorf("missing default-profile warning:\n%s", out) + } +} + +func TestDirRemoveUnassignsAndRegenerates(t *testing.T) { + t.Parallel() + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Name: "X", Email: "x@work"}, + Directories: []string{"/tmp/myrepo/"}, + } + cfg.Current = "work" + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + if err := runDirRemove(nil, []string{"/tmp/myrepo"}); err != nil { + t.Fatalf("runDirRemove: %v", err) + } + + loaded, _ := config.LoadConfig(paths.ConfigFile) + if got := loaded.Profiles["work"].Directories; len(got) != 0 { + t.Errorf("Directories = %v, want empty", got) + } + + if data, err := os.ReadFile(paths.GitConfigFile); err == nil { + if strings.Contains(string(data), "/tmp/myrepo") { + t.Errorf("manifest still references removed dir:\n%s", data) + } + } +} + +func TestDirListShowsAssignments(t *testing.T) { + t.Parallel() + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, _ := config.NewPaths() + + existsDir := filepath.Join(tmpHome, "exists") + _ = os.MkdirAll(existsDir, 0o755) + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + Directories: []string{existsDir + "/", "/nonexistent/path/"}, + } + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + out := captureStdout(t, func() { + if err := runDirList(nil, nil); err != nil { + t.Fatalf("runDirList: %v", err) + } + }) + + if !strings.Contains(out, existsDir) { + t.Errorf("output missing existing dir:\n%s", out) + } + + if !strings.Contains(out, "/nonexistent/path/") { + t.Errorf("output missing nonexistent dir:\n%s", out) + } + + if !strings.Contains(out, "✓") { + t.Errorf("expected ✓ for existing dir:\n%s", out) + } + + if !strings.Contains(out, "✗") { + t.Errorf("expected ✗ for missing dir:\n%s", out) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./cmd/ -run TestDir -v` +Expected: FAIL — `runDirAdd`/`runDirRemove`/`runDirList` undefined. + +- [ ] **Step 3: Implement `cmd/dir.go`** + +Create `cmd/dir.go`: + +```go +package cmd + +import ( + "fmt" + "os" + "sort" + + "github.com/aanogueira/git-context/internal/config" + "github.com/aanogueira/git-context/internal/git" + "github.com/aanogueira/git-context/internal/ui" + "github.com/cockroachdb/errors" + "github.com/spf13/cobra" +) + +var dirCmd = &cobra.Command{ + Use: "dir", + Short: "Manage directory-to-profile assignments", + Long: `Assign filesystem paths to git profiles. When inside an assigned directory, git uses that profile via includeIf.`, +} + +var dirAddCmd = &cobra.Command{ + Use: "add [path] [profile]", + Short: "Assign a directory to a profile", + Args: cobra.ExactArgs(2), + RunE: runDirAdd, +} + +var dirRemoveCmd = &cobra.Command{ + Use: "remove [path]", + Short: "Remove a directory assignment", + Args: cobra.ExactArgs(1), + RunE: runDirRemove, +} + +var dirListCmd = &cobra.Command{ + Use: "list", + Short: "List directory assignments", + RunE: runDirList, +} + +func runDirAdd(cmd *cobra.Command, args []string) error { + rawPath, profileName := args[0], args[1] + + paths, err := config.NewPaths() + if err != nil { + return errors.Wrap(err, "failed to get paths") + } + + cfg, err := config.LoadConfig(paths.ConfigFile) + if err != nil { + return errors.Wrap(err, "failed to load config") + } + + normalized, err := config.NormalizeDir(rawPath) + if err != nil { + ui.PrintError(err.Error()) + + return err + } + + if err := cfg.AssignDir(normalized, profileName); err != nil { + ui.PrintError(err.Error()) + + return err + } + + if _, err := os.Stat(normalized); os.IsNotExist(err) { + ui.PrintWarning(fmt.Sprintf("Directory %s does not exist yet (assignment saved anyway)", normalized)) + } + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + return errors.Wrap(err, "failed to save config") + } + + g := git.NewGit(paths.GitConfigFile) + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + return errors.Wrap(err, "failed to regenerate git config") + } + + ui.PrintSuccess(fmt.Sprintf("Assigned %s → %s", normalized, profileName)) + + if cfg.Current == "" { + ui.PrintWarning("no default profile set; run 'switch ' to apply one outside assigned directories") + } + + return nil +} + +func runDirRemove(cmd *cobra.Command, args []string) error { + rawPath := args[0] + + paths, err := config.NewPaths() + if err != nil { + return errors.Wrap(err, "failed to get paths") + } + + cfg, err := config.LoadConfig(paths.ConfigFile) + if err != nil { + return errors.Wrap(err, "failed to load config") + } + + normalized, err := config.NormalizeDir(rawPath) + if err != nil { + ui.PrintError(err.Error()) + + return err + } + + if err := cfg.UnassignDir(normalized); err != nil { + ui.PrintError(err.Error()) + + return err + } + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + return errors.Wrap(err, "failed to save config") + } + + g := git.NewGit(paths.GitConfigFile) + if err := g.Regenerate(cfg, paths.ProfilesDir); err != nil { + return errors.Wrap(err, "failed to regenerate git config") + } + + ui.PrintSuccess(fmt.Sprintf("Removed assignment for %s", normalized)) + + return nil +} + +func runDirList(cmd *cobra.Command, args []string) error { + paths, err := config.NewPaths() + if err != nil { + return errors.Wrap(err, "failed to get paths") + } + + cfg, err := config.LoadConfig(paths.ConfigFile) + if err != nil { + return errors.Wrap(err, "failed to load config") + } + + assignments := cfg.AssignmentMap() + if len(assignments) == 0 { + ui.PrintWarning("No directory assignments. Use 'git-context dir add ' to add one.") + + return nil + } + + keys := make([]string, 0, len(assignments)) + for k := range assignments { + keys = append(keys, k) + } + + sort.Strings(keys) + + rows := make([][]string, 0, len(keys)) + + for _, dir := range keys { + mark := "✓" + if _, err := os.Stat(dir); os.IsNotExist(err) { + mark = "✗" + } + + rows = append(rows, []string{dir, assignments[dir], mark}) + } + + ui.PrintHeader("Directory Assignments") + ui.PrintTable([]string{"Directory", "Profile", "Exists"}, rows) + + return nil +} + +func init() { + dirCmd.AddCommand(dirAddCmd) + dirCmd.AddCommand(dirRemoveCmd) + dirCmd.AddCommand(dirListCmd) + rootCmd.AddCommand(dirCmd) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./cmd/ -run TestDir -v` +Expected: all PASS. + +Then run the full suite to confirm nothing else broke: + +Run: `go test ./... -v` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/dir.go cmd/dir_test.go +git commit --signoff --gpg-sign -m "feat(dir): add 'dir add/remove/list' subcommands" +``` + +--- + +## Task 16: End-to-end integration test + +Goal: drive the full lifecycle through the CLI surfaces and verify the resulting `~/.gitconfig` is what git would actually consume. + +**Files:** +- Create: `cmd/integration_test.go` + +- [ ] **Step 1: Write the integration test** + +Create `cmd/integration_test.go`: + +```go +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/aanogueira/git-context/internal/config" +) + +// TestEndToEndDirectoryAssignment exercises the full lifecycle: switch to a +// default profile, assign a directory to a different profile, verify that +// inside the assigned directory git resolves the right user.email. +func TestEndToEndDirectoryAssignment(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + paths, err := config.NewPaths() + if err != nil { + t.Fatalf("NewPaths: %v", err) + } + + // Set up two profiles in config.yaml. + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Name: "Andre", Email: "a@work.com"}, + } + cfg.Profiles["personal"] = &config.Profile{ + User: config.UserConfig{Name: "Andre", Email: "a@home.com"}, + } + + if err := cfg.SaveConfig(paths.ConfigFile); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + // Switch makes 'work' the default and regenerates. + if err := runSwitch(nil, []string{"work"}); err != nil { + t.Fatalf("runSwitch: %v", err) + } + + // Create a real git repo and assign it to 'personal'. + repo := filepath.Join(tmpHome, "personal-repo") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + + if out, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil { + t.Fatalf("git init: %v\n%s", err, out) + } + + if err := runDirAdd(nil, []string{repo, "personal"}); err != nil { + t.Fatalf("runDirAdd: %v", err) + } + + // Outside the repo (use HOME), default 'work' should be effective. + out, err := exec.Command("git", "-C", tmpHome, "config", "user.email").Output() + if err != nil { + t.Fatalf("git config user.email outside: %v", err) + } + + if got := strings.TrimSpace(string(out)); got != "a@work.com" { + t.Errorf("default user.email = %q, want a@work.com", got) + } + + // Inside the repo, 'personal' should override. + out, err = exec.Command("git", "-C", repo, "config", "user.email").Output() + if err != nil { + t.Fatalf("git config user.email inside: %v", err) + } + + if got := strings.TrimSpace(string(out)); got != "a@home.com" { + t.Errorf("override user.email = %q, want a@home.com", got) + } + + // dir remove undoes the override. + if err := runDirRemove(nil, []string{repo}); err != nil { + t.Fatalf("runDirRemove: %v", err) + } + + out, err = exec.Command("git", "-C", repo, "config", "user.email").Output() + if err != nil { + t.Fatalf("git config user.email after remove: %v", err) + } + + if got := strings.TrimSpace(string(out)); got != "a@work.com" { + t.Errorf("after dir remove, user.email = %q, want a@work.com", got) + } + + // Sanity: profile files exist on disk. + for _, name := range []string{"work", "personal"} { + if _, err := os.Stat(filepath.Join(paths.ProfilesDir, name+".gitconfig")); err != nil { + t.Errorf("expected %s.gitconfig: %v", name, err) + } + } + + // Sanity: per-profile file content matches the profile. + workFile := filepath.Join(paths.ProfilesDir, "work.gitconfig") + data, _ := os.ReadFile(workFile) + if !strings.Contains(string(data), "a@work.com") { + t.Errorf("work.gitconfig missing email:\n%s", data) + } +} +``` + +- [ ] **Step 2: Run the test** + +Run: `go test ./cmd/ -run TestEndToEndDirectoryAssignment -v` +Expected: PASS. If `git` is not on PATH, the test skips. + +- [ ] **Step 3: Run the full suite** + +Run: `go test ./... -v` +Expected: all PASS (or skipped where git isn't available). + +- [ ] **Step 4: Run linter** + +Run: `make lint` +Expected: no issues. If the linter complains about `nestif`/`gocognit` thresholds in new files, address by extracting helpers or apply the existing repo-style `//nolint:` directive used in `git.go`. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/integration_test.go +git commit --signoff --gpg-sign -m "test: end-to-end directory-based profile lifecycle" +``` + +--- + +## Task 17: README — document `dir` commands and the new file layout + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add a "Directory-Based Profiles" section** + +Insert a new section in `README.md` after "All Available Commands" (around line 187), and update the command table. + +In the command table, add three rows: + +```markdown +| `git-context dir add ` | Assign a directory to a profile (auto-applied via includeIf) | +| `git-context dir remove ` | Remove a directory assignment | +| `git-context dir list` | List all directory assignments | +``` + +Then add a new section: + +```markdown +### Directory-Based Profiles + +Assign filesystem paths to profiles so git applies the right identity automatically when you're inside them — no need to remember to `switch`. + +```bash +# Make 'work' the default profile (used everywhere unless overridden) +git-context switch work + +# Assign specific directories to other profiles +git-context dir add ~/projects/personal personal +git-context dir add ~/Mollie work + +# See all assignments +git-context dir list +``` + +Under the hood git-context generates one gitconfig file per profile under `~/.config/git-context/profiles/` and rewrites `~/.gitconfig` as a thin manifest of `[include]` and `[includeIf "gitdir:..."]` blocks. Every mutating command regenerates these files, so the YAML at `~/.config/git-context/config.yaml` is always the source of truth. + +> Note: git-context owns `~/.gitconfig` end-to-end — don't run `git config --global` directly. Edit the YAML or use the CLI instead. +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit --signoff --gpg-sign -m "docs: document dir commands and new file layout" +``` + +--- + +## Self-Review Checklist (run before handoff) + +- [ ] Every spec section has at least one task implementing it: + - File layout → Tasks 5, 6, 7, 8 + - Configuration schema → Tasks 1, 2 + - Path normalization rules → Task 2 + - Validation (conflict + nonexistent warning) → Task 4 (config), Task 15 (CLI warning) + - `dir add/remove/list` → Task 15 + - Changed `switch` → Task 9 + - Changed `current` → Task 13 + - Changed `add` → Task 14 + - Changed `remove` → Task 10 + - Changed `list` → Task 11 + - Changed `show` → Task 12 + - Atomicity (`*.tmp` + rename) → Task 6 + - Migration (implicit on first regenerate) → covered by Task 9 behavior + - Edge case: no default profile + `dir add` warning → Task 15 test + - Testing additions → Tasks 2-15 each ship tests + - Out-of-scope items → not implemented (correct) +- [ ] No placeholders (`TBD`, "implement later", "add error handling") remain +- [ ] Function and method names are consistent across tasks (`Regenerate`, `WriteProfileFile`, `WriteRootConfig`, `NormalizeDir`, `LookupDir`, `AssignmentMap`, `AssignDir`, `UnassignDir`, `runDirAdd`, `runDirRemove`, `runDirList`) +- [ ] Every "Run:" step has an "Expected:" outcome +- [ ] Every code-modifying step shows the actual code diff --git a/docs/superpowers/specs/2026-05-04-directory-based-profile-assignment-design.md b/docs/superpowers/specs/2026-05-04-directory-based-profile-assignment-design.md new file mode 100644 index 0000000..77bc48b --- /dev/null +++ b/docs/superpowers/specs/2026-05-04-directory-based-profile-assignment-design.md @@ -0,0 +1,212 @@ +# Directory-Based Profile Assignment + +**Date:** 2026-05-04 +**Status:** Approved (pending implementation plan) + +## Problem + +Today `git-context switch ` is the only way to apply a profile, and it +applies globally by overwriting `~/.gitconfig`. Users with multiple +client/work/personal repositories must remember to switch before working in +each one, and forgetting silently produces commits under the wrong identity. + +We want repositories under specific paths (e.g. `~/Mollie`, `~/projects/work`) +to use a designated profile automatically, while a configured default profile +applies everywhere else. The mechanism should be declarative — captured in +config files — not implemented as a shell hook that watches `cd`. + +## Approach + +Use git's native `includeIf "gitdir:..."` directive. `~/.gitconfig` becomes a +thin manifest: an unconditional `[include]` referencing the default profile, +followed by one `[includeIf]` block per assigned directory. Each profile is +materialized to its own gitconfig file on disk; the manifest only references +them. + +`git-context` owns `~/.gitconfig` end-to-end (regenerated on every mutating +command, with a single backup taken at the start). The user's source of truth +remains `~/.config/git-context/config.yaml`. + +## File Layout + +``` +~/.config/git-context/ +├── config.yaml # source of truth +└── profiles/ + ├── work.gitconfig # generated, one per profile + ├── personal.gitconfig # generated + └── university.gitconfig # generated + +~/.gitconfig # generated, thin manifest +``` + +Example `~/.gitconfig` after `switch work` plus two assigned directories: + +```gitconfig +# Generated by git-context — do not edit. Run `git-context` to manage. +[include] + path = /Users/andre/.config/git-context/profiles/work.gitconfig + +[includeIf "gitdir:/Users/andre/projects/personal/"] + path = /Users/andre/.config/git-context/profiles/personal.gitconfig + +[includeIf "gitdir:/Users/andre/Mollie/"] + path = /Users/andre/.config/git-context/profiles/work.gitconfig +``` + +Each `.gitconfig` contains the merged (global + profile) content +rendered in git-config format — the same content `switch` writes today, +written to a per-profile file instead of inlined into `~/.gitconfig`. + +## Configuration Schema + +`directories` is a new optional field on each profile: + +```yaml +global: + core: { editor: nvim } + commit: { gpgsign: true } + +profiles: + work: + user: + name: "Andre Nogueira" + email: "aanogueira@techquests.dev" + signingkey: "A0A90F4231D8B028" + directories: + - ~/Mollie + - ~/projects/work + url: + - { pattern: "ssh://git@github.com/", insteadOf: "https://github.com/" } + + personal: + user: + name: "Andre Nogueira" + email: "aanogueira@protonmail.com" + directories: + - ~/projects/personal + +previous: personal +``` + +### Path normalization (applied on save) + +- `~` is expanded to absolute (`/Users/andre/...`) +- Relative paths are resolved against `$PWD`, then made absolute +- A trailing slash is added internally so `gitdir:` matches the whole subtree +- Paths containing `*` or `**` are passed through unchanged (git glob syntax) +- Always emitted as case-sensitive `gitdir:`. A future `case_insensitive: true` + per-directory knob can be added if needed (out of scope here) + +### Validation + +- `dir add` rejects a path that is already assigned to another profile. + Error: `"path X is already assigned to profile Y; run 'dir remove' first"` +- `dir add` warns but does not reject if the directory does not exist on disk + (user may be setting up before cloning) + +## Command Surface + +### New commands + +| Command | Behavior | +|---|---| +| `git-context dir add ` | Adds path to the profile's `directories`, regenerates `~/.gitconfig` and per-profile files | +| `git-context dir remove ` | Removes the assignment, regenerates | +| `git-context dir list` | Prints a table: directory, profile, exists?(✓/✗) | + +### Changed commands + +| Command | New behavior | +|---|---| +| `switch ` | Writes `.gitconfig` and regenerates `~/.gitconfig` with `` as the unconditional `[include]`. No longer inlines profile content into `~/.gitconfig`. | +| `current` | Reports the **default** profile (what `switch` set). Adds a second line: `Effective in : ` — runs `git config --show-origin user.email` to show what git resolves here, surfacing when an `includeIf` is overriding | +| `add ` | Interactive flow gains an optional "Assign directories?" prompt at the end | +| `remove ` | If the profile has assigned directories, prompts for confirmation; on accept, drops the directories and regenerates | +| `list` | Adds a "Dirs" column showing assignment count (details via `show ` or `dir list`) | +| `show ` | Includes the `directories:` list in output | + +### Unchanged + +`init`, `switch -` (still flips default to previous), `--help`, `--version`. + +## Internals + +### Code organization + +Extends the existing layout, no large rewrite: + +- `internal/config/config.go` + - Add `Directories []string` field to `Profile` + - Add `LookupDir(path string) (profile string, ok bool)` for reverse lookup + - Add path normalization helpers (`NormalizeDir`) +- `internal/git/git.go` — split `WriteConfig` into: + - `WriteProfileFile(name string, merged *config.Profile) error` — writes + `~/.config/git-context/profiles/.gitconfig` + - `WriteRootConfig(defaultProfile string, assignments map[string]string) error` + — writes `~/.gitconfig` manifest (`assignments` is `path → profile`) + - `Regenerate(cfg *config.Config) error` — orchestrator: writes all profile + files plus the root manifest in one go (called by every mutating command) +- `cmd/dir.go` — new file: parent `dir` cobra command plus `add` / `remove` / + `list` subcommands +- `cmd/current.go` — extend with the "effective" lookup (`git config + --show-origin user.email` in `$PWD`) + +### Atomicity + +`Regenerate` writes each file to `.tmp` and renames into place. A backup +of `~/.gitconfig` is taken once at the start of any mutating command (same as +today). If any write fails, abort and print which file errored — partial state +is recoverable from the backup. + +### Migration on first run after upgrade + +Detect old-style `~/.gitconfig` (profile content written directly, no header +marker `# Generated by git-context`) implicitly: the next mutating command +(`switch`, `dir add`, `dir remove`) regenerates the file in the new format. +The pre-existing backup logic preserves the old file. No separate migration +command is needed. + +## Edge Cases + +- **No default profile set yet** (fresh install, `switch` never called) and + user runs `dir add`: root manifest emits only `[includeIf]` blocks, no + unconditional `[include]`. Outside assigned directories, git falls back to + its own defaults. Print a one-line warning: + `"no default profile set; run 'switch ' to apply one outside assigned directories"` +- **Profile referenced in a directory assignment gets removed**: existing + `remove` flow handles this — drops the directories after confirmation +- **User edits `~/.gitconfig` manually**: next regenerate clobbers it. The + header line is the only signal. Acceptable per the "git-context owns the + file" decision +- **Path collision with a hand-written `gitdir:`**: same answer; git-context + owns the file end-to-end + +## Testing + +Additions to the existing test suites: + +- **`internal/config`** + - Path normalization: `~` expansion, relative-path resolution, trailing-slash + behavior, glob pass-through + - `LookupDir` round-trip + - `dir add` rejects same path assigned twice +- **`internal/git`** + - Round-trip: `WriteRootConfig` → parse with `git config -f --list` to + verify git accepts the output + - `Regenerate` with N profiles and M assignments produces deterministic + output (sorted keys) +- **`cmd`** + - `dir add` / `dir remove` / `dir list` happy paths + - "no default profile set" warning emitted when expected + - Same-path-twice rejection surfaces the correct error + +## Out of Scope + +- Shell hook integration (`chpwd`, direnv) — the entire point of choosing + `includeIf` is to avoid this +- `onbranch:` or `hasconfig:` matching — directory matching only, for now +- A `git-context dir move ` shortcut — `remove` then `add` + is fine +- Case-insensitive matching as a per-directory option — defer until requested +- Per-repo `.git-context.yaml` overrides — defer diff --git a/internal/config/config.go b/internal/config/config.go index efd343f..23c89ae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,8 +4,6 @@ import ( "fmt" "maps" "os" - "os/exec" - "strings" "gopkg.in/yaml.v3" @@ -37,6 +35,7 @@ type Profile struct { Rerere map[string]any `yaml:"rerere,omitempty"` Pager map[string]any `yaml:"pager,omitempty"` Tag map[string]any `yaml:"tag,omitempty"` + Directories []string `yaml:"directories,omitempty"` URL []URLConfig `yaml:"url,omitempty"` User UserConfig `yaml:"user,omitempty"` } @@ -58,7 +57,7 @@ type URLConfig struct { type Config struct { Global map[string]any `yaml:"global"` Profiles map[string]*Profile `yaml:"profiles"` - Current string `yaml:"-"` // Not saved, determined at runtime + Current string `yaml:"current,omitempty"` Previous string `yaml:"previous,omitempty"` } @@ -89,9 +88,6 @@ func LoadConfig(configFile string) (*Config, error) { return nil, errors.Wrap(err, "failed to parse config file") } - // Determine current profile by checking git config - config.determineCurrent() - return config, nil } @@ -208,38 +204,6 @@ func (c *Config) Merge(profileName string) (*Profile, error) { return merged, nil } -// determineCurrent determines which profile is currently active by checking git config. -func (c *Config) determineCurrent() { - // Get current git user.name from git config - cmd := exec.Command("git", "config", "--global", "user.name") - - output, err := cmd.Output() - if err != nil { - return // Can't determine current profile - } - - currentName := strings.TrimSpace(string(output)) - - // Get current git user.email from git config - cmd = exec.Command("git", "config", "--global", "user.email") - - output, err = cmd.Output() - if err != nil { - return // Can't determine current profile - } - - currentEmail := strings.TrimSpace(string(output)) - - // Match against profiles - for profileName, profile := range c.Profiles { - if profile.User.Name == currentName && profile.User.Email == currentEmail { - c.Current = profileName - - return - } - } -} - // mergeMap merges two maps, with values from profileConfig overriding globalConfig. func mergeMap( globalConfig map[string]any, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2fd51b5..1596b6a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -464,79 +464,60 @@ func TestMergeWithInterfaceURLs(t *testing.T) { } } -func TestDetermineCurrent(t *testing.T) { +func TestCurrentPersistedInYAML(t *testing.T) { t.Parallel() - cfg := NewConfig() + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") - // Add profiles + cfg := NewConfig() if err := cfg.AddProfile("work", &Profile{ - User: UserConfig{ - Name: "Work User", - Email: "work@example.com", - }, + User: UserConfig{Name: "Work User", Email: "work@example.com"}, }); err != nil { t.Fatalf("AddProfile failed: %v", err) } - if err := cfg.AddProfile("personal", &Profile{ - User: UserConfig{ - Name: "Personal User", - Email: "personal@example.com", - }, - }); err != nil { - t.Fatalf("AddProfile failed: %v", err) + cfg.Current = "work" + + if err := cfg.SaveConfig(configFile); err != nil { + t.Fatalf("SaveConfig failed: %v", err) } - // determineCurrent is called during LoadConfig, but we can test it indirectly - // by checking that Current is empty when no git config matches - if cfg.Current != "" { - t.Errorf("Current should be empty when no matching git config, got: %s", cfg.Current) + loaded, err := LoadConfig(configFile) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if loaded.Current != "work" { + t.Errorf("Current not persisted: got %q, want %q", loaded.Current, "work") } } -func TestDetermineCurrentDirectly(t *testing.T) { +func TestLoadConfigEmptyCurrent(t *testing.T) { t.Parallel() - cfg := NewConfig() - - // Test with no profiles - cfg.determineCurrent() + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") - if cfg.Current != "" { - t.Errorf("Current should be empty with no profiles, got: %s", cfg.Current) + cfg := NewConfig() + if err := cfg.AddProfile("work", &Profile{ + User: UserConfig{Name: "Work User", Email: "work@example.com"}, + }); err != nil { + t.Fatalf("AddProfile failed: %v", err) } - // Add a profile - cfg.Profiles["test"] = &Profile{ - User: UserConfig{ - Name: "Test User", - Email: "test@example.com", - }, + if err := cfg.SaveConfig(configFile); err != nil { + t.Fatalf("SaveConfig failed: %v", err) } - // Call determineCurrent - won't match unless git config actually has these values - // This tests the code path for non-matching profiles - cfg.determineCurrent() - // Current will be empty unless the system's actual git config matches - // We're just ensuring no panic/error occurs - - // Add multiple profiles to test the matching loop - cfg.Profiles["work"] = &Profile{ - User: UserConfig{ - Name: "Work User", - Email: "work@example.com", - }, - } - cfg.Profiles["personal"] = &Profile{ - User: UserConfig{ - Name: "Personal User", - Email: "personal@example.com", - }, + loaded, err := LoadConfig(configFile) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) } - cfg.determineCurrent() - // Again, just ensuring the function completes without error + if loaded.Current != "" { + t.Errorf("Current should be empty when not set, got %q", loaded.Current) + } } func TestMergeMapEdgeCases(t *testing.T) { @@ -587,3 +568,38 @@ func TestMergeMapEdgeCases(t *testing.T) { t.Error("Profile value should be included") } } + +func TestProfileYAMLRoundTripWithDirectories(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{ + User: UserConfig{Name: "Andre", Email: "a@work.com"}, + Directories: []string{"/Users/andre/projects/work", "/Users/andre/Mollie"}, + } + + if err := cfg.SaveConfig(configFile); err != nil { + t.Fatalf("SaveConfig failed: %v", err) + } + + loaded, err := LoadConfig(configFile) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + got := loaded.Profiles["work"].Directories + want := []string{"/Users/andre/projects/work", "/Users/andre/Mollie"} + + if len(got) != len(want) { + t.Fatalf("Directories length: got %d, want %d", len(got), len(want)) + } + + for i := range want { + if got[i] != want[i] { + t.Errorf("Directories[%d]: got %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/internal/config/dirs.go b/internal/config/dirs.go new file mode 100644 index 0000000..35bd4b2 --- /dev/null +++ b/internal/config/dirs.go @@ -0,0 +1,131 @@ +package config + +import ( + "os" + "path/filepath" + "slices" + "strings" + + "github.com/cockroachdb/errors" +) + +// NormalizeDir prepares a user-supplied directory path for use in a +// `gitdir:` includeIf directive. +// +// - Empty input is rejected. +// - Inputs containing `*` are passed through unchanged (treated as a +// git-style glob). +// - `~` is expanded to the user's home directory. +// - Relative paths are resolved against the current working directory. +// - A trailing slash is always appended so the directive matches the +// whole subtree, not just the directory itself. +func NormalizeDir(path string) (string, error) { + if path == "" { + return "", errors.New("directory path is empty") + } + + if strings.Contains(path, "*") { + return path, nil + } + + if strings.HasPrefix(path, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "", errors.Wrap(err, "failed to get user home directory") + } + + path = filepath.Join(home, strings.TrimPrefix(path, "~")) + } + + if !filepath.IsAbs(path) { + abs, err := filepath.Abs(path) + if err != nil { + return "", errors.Wrap(err, "failed to resolve relative path") + } + + path = abs + } + + if !strings.HasSuffix(path, "/") { + path += "/" + } + + return path, nil +} + +// LookupDir returns the profile name that owns the given directory path, +// or ("", false) if no profile claims it. The path must already be in its +// normalized form. +func (c *Config) LookupDir(path string) (string, bool) { + for name, profile := range c.Profiles { + if slices.Contains(profile.Directories, path) { + return name, true + } + } + + return "", false +} + +// AssignmentMap returns a flat path-to-profile map across all profiles. +// Used to emit the [includeIf] block list in deterministic order. +func (c *Config) AssignmentMap() map[string]string { + out := make(map[string]string) + + for name, profile := range c.Profiles { + for _, dir := range profile.Directories { + out[dir] = name + } + } + + return out +} + +// AssignDir adds `path` to the named profile's Directories list. +// Returns an error if: +// - the profile does not exist, or +// - the path is already assigned to a different profile. +// +// Re-assigning the same path to its current profile is a no-op. +func (c *Config) AssignDir(path, profileName string) error { + profile, exists := c.Profiles[profileName] + if !exists { + return errors.WithStack(errors.Newf("profile %q does not exist", profileName)) + } + + if owner, ok := c.LookupDir(path); ok { + if owner == profileName { + return nil + } + + return errors.WithStack(errors.Newf( + "path %q is already assigned to profile %q; run 'dir remove' first", + path, owner, + )) + } + + profile.Directories = append(profile.Directories, path) + + return nil +} + +// UnassignDir removes `path` from whichever profile owns it. +// Returns an error if no profile owns the path. +func (c *Config) UnassignDir(path string) error { + owner, ok := c.LookupDir(path) + if !ok { + return errors.WithStack(errors.Newf("path %q is not assigned to any profile", path)) + } + + profile := c.Profiles[owner] + filtered := profile.Directories[:0] + + for _, d := range profile.Directories { + if d != path { + filtered = append(filtered, d) + } + } + + profile.Directories = filtered + + return nil +} diff --git a/internal/config/dirs_test.go b/internal/config/dirs_test.go new file mode 100644 index 0000000..87d60c1 --- /dev/null +++ b/internal/config/dirs_test.go @@ -0,0 +1,201 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNormalizeDir(t *testing.T) { + t.Parallel() + + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("UserHomeDir failed: %v", err) + } + + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd failed: %v", err) + } + + tests := []struct { + name string + in string + want string + }{ + {"absolute path gets trailing slash", "/Users/x/projects/work", "/Users/x/projects/work/"}, + { + "absolute path keeps existing trailing slash", + "/Users/x/projects/work/", + "/Users/x/projects/work/", + }, + {"tilde expands to home", "~/projects/work", filepath.Join(home, "projects", "work") + "/"}, + {"relative resolves against cwd", "./foo", filepath.Join(cwd, "foo") + "/"}, + {"single-star glob passes through unchanged", "~/work/*/repo", "~/work/*/repo"}, + {"double-star glob passes through unchanged", "~/work/**", "~/work/**"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := NormalizeDir(tc.in) + if err != nil { + t.Fatalf("NormalizeDir(%q) error: %v", tc.in, err) + } + + if got != tc.want { + t.Errorf("NormalizeDir(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestNormalizeDirEmpty(t *testing.T) { + t.Parallel() + + if _, err := NormalizeDir(""); err == nil { + t.Error("expected error for empty path, got nil") + } +} + +func TestLookupDir(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{ + Directories: []string{"/Users/x/work/", "/Users/x/Mollie/"}, + } + cfg.Profiles["personal"] = &Profile{ + Directories: []string{"/Users/x/personal/"}, + } + + if got, ok := cfg.LookupDir("/Users/x/work/"); !ok || got != "work" { + t.Errorf("LookupDir(work) = (%q, %v), want (\"work\", true)", got, ok) + } + + if got, ok := cfg.LookupDir("/Users/x/personal/"); !ok || got != "personal" { + t.Errorf("LookupDir(personal) = (%q, %v), want (\"personal\", true)", got, ok) + } + + if _, ok := cfg.LookupDir("/Users/x/none/"); ok { + t.Error("LookupDir(none) ok = true, want false") + } +} + +func TestAssignmentMap(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{ + Directories: []string{"/Users/x/work/", "/Users/x/Mollie/"}, + } + cfg.Profiles["personal"] = &Profile{ + Directories: []string{"/Users/x/personal/"}, + } + + got := cfg.AssignmentMap() + + if len(got) != 3 { + t.Fatalf("AssignmentMap len = %d, want 3", len(got)) + } + + wants := map[string]string{ + "/Users/x/work/": "work", + "/Users/x/Mollie/": "work", + "/Users/x/personal/": "personal", + } + + for path, wantProfile := range wants { + if got[path] != wantProfile { + t.Errorf("AssignmentMap[%q] = %q, want %q", path, got[path], wantProfile) + } + } +} + +func TestAssignDirSuccess(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{User: UserConfig{Name: "X"}} + + if err := cfg.AssignDir("/Users/x/work/", "work"); err != nil { + t.Fatalf("AssignDir error: %v", err) + } + + if got := cfg.Profiles["work"].Directories; len(got) != 1 || got[0] != "/Users/x/work/" { + t.Errorf("Directories = %v, want [/Users/x/work/]", got) + } +} + +func TestAssignDirRejectsUnknownProfile(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + + if err := cfg.AssignDir("/Users/x/work/", "ghost"); err == nil { + t.Error("expected error for unknown profile, got nil") + } +} + +func TestAssignDirRejectsDuplicateAcrossProfiles(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{Directories: []string{"/Users/x/shared/"}} + cfg.Profiles["personal"] = &Profile{} + + err := cfg.AssignDir("/Users/x/shared/", "personal") + if err == nil { + t.Fatal("expected error for duplicate path, got nil") + } + + if !strings.Contains(err.Error(), "already assigned") { + t.Errorf("error = %q, want it to mention 'already assigned'", err.Error()) + } +} + +func TestAssignDirIdempotentSameProfile(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{Directories: []string{"/Users/x/work/"}} + + if err := cfg.AssignDir("/Users/x/work/", "work"); err != nil { + t.Fatalf("AssignDir error: %v", err) + } + + if got := len(cfg.Profiles["work"].Directories); got != 1 { + t.Errorf("Directories len = %d, want 1 (no duplication)", got) + } +} + +func TestUnassignDirSuccess(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + cfg.Profiles["work"] = &Profile{ + Directories: []string{"/Users/x/work/", "/Users/x/Mollie/"}, + } + + if err := cfg.UnassignDir("/Users/x/work/"); err != nil { + t.Fatalf("UnassignDir error: %v", err) + } + + got := cfg.Profiles["work"].Directories + if len(got) != 1 || got[0] != "/Users/x/Mollie/" { + t.Errorf("Directories = %v, want [/Users/x/Mollie/]", got) + } +} + +func TestUnassignDirNotFound(t *testing.T) { + t.Parallel() + + cfg := NewConfig() + + if err := cfg.UnassignDir("/Users/x/missing/"); err == nil { + t.Error("expected error for missing path, got nil") + } +} diff --git a/internal/config/paths.go b/internal/config/paths.go index b9b4b13..9df61fc 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -11,6 +11,7 @@ import ( type Paths struct { ConfigDir string ConfigFile string + ProfilesDir string GitConfigFile string GitConfigBackup string } @@ -24,17 +25,22 @@ func NewPaths() (*Paths, error) { configDir := filepath.Join(home, ".config", "git-context") configFile := filepath.Join(configDir, "config.yaml") + profilesDir := filepath.Join(configDir, "profiles") gitConfigFile := filepath.Join(home, ".gitconfig") gitConfigBackup := filepath.Join(home, ".gitconfig.bak") - // Create config directory if it doesn't exist if err := os.MkdirAll(configDir, 0o755); err != nil { return nil, errors.Wrap(err, "failed to create config directory") } + if err := os.MkdirAll(profilesDir, 0o755); err != nil { + return nil, errors.Wrap(err, "failed to create profiles directory") + } + return &Paths{ ConfigDir: configDir, ConfigFile: configFile, + ProfilesDir: profilesDir, GitConfigFile: gitConfigFile, GitConfigBackup: gitConfigBackup, }, nil diff --git a/internal/config/paths_test.go b/internal/config/paths_test.go index b507670..ce81bb6 100644 --- a/internal/config/paths_test.go +++ b/internal/config/paths_test.go @@ -81,3 +81,25 @@ func TestNewPathsCreatesDirectory(t *testing.T) { t.Error("ConfigDir path exists but is not a directory") } } + +func TestPathsHasProfilesDir(t *testing.T) { + t.Parallel() + + paths, err := NewPaths() + if err != nil { + t.Fatalf("NewPaths failed: %v", err) + } + + if paths.ProfilesDir == "" { + t.Fatal("ProfilesDir is empty") + } + + want := filepath.Join(paths.ConfigDir, "profiles") + if paths.ProfilesDir != want { + t.Errorf("ProfilesDir = %q, want %q", paths.ProfilesDir, want) + } + + if _, err := os.Stat(paths.ProfilesDir); err != nil { + t.Errorf("ProfilesDir was not created: %v", err) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index 403b5f1..24a83d3 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -1,10 +1,14 @@ package git import ( + "bytes" "fmt" "os" + "path/filepath" + "sort" "strings" + "github.com/aanogueira/git-context/internal/config" "github.com/cockroachdb/errors" ) @@ -20,34 +24,236 @@ func NewGit(globalConfigPath string) *Git { } } -// WriteConfig writes configuration to git global config. -func (g *Git) WriteConfig(config map[string]any) error { +// BackupConfig creates a backup of the git config, but skips the backup +// when the source is a git-context-generated manifest. The backup exists +// to preserve the user's pre-migration `~/.gitconfig` on the first +// switch after upgrade; once we've taken over the file there's nothing +// useful to back up, and re-backing up would clobber the original. +func (g *Git) BackupConfig(backupPath string) error { + data, err := os.ReadFile(g.globalConfigPath) + if err != nil { + if os.IsNotExist(err) { + return nil // No config to backup + } + + return errors.Wrap(err, "failed to read git config for backup") + } + + if bytes.HasPrefix(data, []byte(rootConfigHeader)) { + return nil // Source is a generated manifest; nothing useful to back up. + } + + if err := os.WriteFile(backupPath, data, 0o644); err != nil { + return errors.Wrap(err, "failed to create backup") + } + + return nil +} + +// WriteProfileFile writes a flat key→value git config map to `path` using +// atomic temp-file-and-rename semantics. +// +// Receiver `g` is unused here; the method form is kept for API symmetry +// with WriteRootConfig and Regenerate, which do consume receiver state. +func (g *Git) WriteProfileFile(path string, config map[string]any) error { content := buildGitConfig(config) - if err := os.WriteFile(g.globalConfigPath, []byte(content), 0o644); err != nil { - return errors.Wrap(err, "failed to write git config") + + return atomicWrite(path, []byte(content)) +} + +// atomicWrite writes data to a sibling `.tmp` file then renames it into place. +// The rename is atomic with respect to concurrent readers (same-filesystem +// rename(2) on POSIX). It is NOT crash-safe: there is no fsync, so a power +// loss between write and rename can leave a partial file. +func atomicWrite(path string, data []byte) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return errors.Wrap(err, "failed to write temp file") + } + + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + + return errors.Wrap(err, "failed to rename temp file into place") } return nil } -// BackupConfig creates a backup of the git config. -func (g *Git) BackupConfig(backupPath string) error { +// rootConfigHeader is emitted at the top of the generated ~/.gitconfig. +// Used both to inform users that the file is managed and (optionally in +// the future) to detect prior generations. +const rootConfigHeader = "# Generated by git-context — do not edit. " + + "Run `git-context` to manage.\n" + +// WriteRootConfig generates the ~/.gitconfig manifest: +// - a header marker +// - one unconditional [include] block for `defaultProfilePath`, if set +// - one [includeIf "gitdir:"] block per (path → profile-file) entry +// +// `assignments` keys are normalized directory paths (with trailing `/`), +// values are absolute paths to per-profile gitconfig files. Blocks are +// emitted in sorted key order so output is deterministic. +// +// If the default is empty AND assignments is empty, no file is written +// (avoids clobbering an existing user-managed ~/.gitconfig before any +// git-context state exists). +// +// All embedded paths are slash-normalized via toGitPath so the output +// is parseable on Windows, where filepath.Join produces backslashes +// that git's config parser rejects in unquoted `path =` values. +func (g *Git) WriteRootConfig(defaultProfilePath string, assignments map[string]string) error { + if defaultProfilePath == "" && len(assignments) == 0 { + return g.removeManagedManifest() + } + + var b strings.Builder + + b.WriteString(rootConfigHeader) + b.WriteString("\n") + + if defaultProfilePath != "" { + fmt.Fprintf(&b, "[include]\n\tpath = %s\n\n", toGitPath(defaultProfilePath)) + } + + keys := make([]string, 0, len(assignments)) + for k := range assignments { + keys = append(keys, k) + } + + sort.Strings(keys) + + for _, dir := range keys { + fmt.Fprintf(&b, "[includeIf \"gitdir:%s\"]\n\tpath = %s\n\n", + toGitPath(dir), toGitPath(assignments[dir])) + } + + return atomicWrite(g.globalConfigPath, []byte(b.String())) +} + +// Regenerate writes one gitconfig file per profile under `profilesDir`, +// then writes the root manifest at `g.globalConfigPath`. Called by every +// mutating command (`switch`, `add` with dirs, `remove`, `dir add/remove`). +// +// Per-profile files use the merged (global + profile) configuration so they +// behave identically to the file `switch` used to write inline. +// +// The root manifest is written by WriteRootConfig and may be a no-op if no +// default profile is set and no directories are assigned. +// +// Failure semantics: writes are not transactional across files. Each +// individual file write is atomic (temp+rename), but if a per-profile write +// fails midway the earlier profile files remain on disk and the root +// manifest is NOT updated — `~/.gitconfig` continues to point at the +// previous set. The next successful Regenerate fully reconciles state. +func (g *Git) Regenerate(cfg *config.Config, profilesDir string) error { + for name := range cfg.Profiles { + merged, err := cfg.Merge(name) + if err != nil { + return errors.Wrapf(err, "failed to merge profile %q", name) + } + + path := filepath.Join(profilesDir, name+".gitconfig") + if err := g.WriteProfileFile(path, profileToFlatConfig(merged)); err != nil { + return errors.Wrapf(err, "failed to write profile file for %q", name) + } + } + + defaultPath := "" + if cfg.Current != "" { + defaultPath = filepath.Join(profilesDir, cfg.Current+".gitconfig") + } + + assignments := make(map[string]string) + for path, profileName := range cfg.AssignmentMap() { + assignments[path] = filepath.Join(profilesDir, profileName+".gitconfig") + } + + return g.WriteRootConfig(defaultPath, assignments) +} + +// removeManagedManifest deletes the existing root config only if it is one +// of our generated manifests (identified by the rootConfigHeader prefix). +// This prevents stale includes from lingering after the last profile or +// assignment is removed, while leaving any user-authored ~/.gitconfig +// untouched. +func (g *Git) removeManagedManifest() error { data, err := os.ReadFile(g.globalConfigPath) if err != nil { if os.IsNotExist(err) { - return nil // No config to backup + return nil } - return errors.Wrap(err, "failed to read git config for backup") + return errors.Wrap(err, "failed to read existing git config") } - if err := os.WriteFile(backupPath, data, 0o644); err != nil { - return errors.Wrap(err, "failed to create backup") + if !bytes.HasPrefix(data, []byte(rootConfigHeader)) { + return nil + } + + if err := os.Remove(g.globalConfigPath); err != nil && !os.IsNotExist(err) { + return errors.Wrap(err, "failed to remove stale git config manifest") } return nil } +// toGitPath converts any backslashes in a filesystem path to forward +// slashes so it can be safely embedded in a git config value. git's +// parser rejects unquoted backslashes in `path =` lines on Windows, +// where filepath.Join produces them. filepath.ToSlash is insufficient +// because it only swaps the OS-specific separator — on POSIX it is a +// no-op and would leave Windows-shaped paths (e.g. produced in tests) +// unconverted. +func toGitPath(p string) string { + return strings.ReplaceAll(p, `\`, `/`) +} + +// profileToFlatConfig converts a Profile struct into the flat dotted-key +// map (`user.name`, `url "...".insteadOf`, etc.) that buildGitConfig +// expects. It is the only flatten implementation in the codebase; the +// pre-refactor cmd-side copy was removed in Task 9. +func profileToFlatConfig(profile *config.Profile) map[string]any { + out := make(map[string]any) + + if profile.User.Name != "" { + out["user.name"] = profile.User.Name + } + + if profile.User.Email != "" { + out["user.email"] = profile.User.Email + } + + if profile.User.SigningKey != "" { + out["user.signingkey"] = profile.User.SigningKey + } + + for _, url := range profile.URL { + key := fmt.Sprintf("url \"%s\".insteadOf", url.Pattern) + out[key] = url.InsteadOf + } + + for _, section := range config.ConfigSections { + if sectionMap := profile.GetSection(section); sectionMap != nil { + flattenInto(out, section, sectionMap) + } + } + + return out +} + +// flattenInto walks a nested map and writes leaf values keyed by dotted path. +func flattenInto(out map[string]any, prefix string, values map[string]any) { + for k, v := range values { + key := prefix + "." + k + if m, ok := v.(map[string]any); ok { + flattenInto(out, key, m) + } else { + out[key] = v + } + } +} + // buildGitConfig builds git config format from a map of key-value pairs. // It handles both regular dotted notation and quoted subsections. // Returns a formatted git config string with sections and key-value pairs. diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 0319762..40b92f0 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -2,9 +2,12 @@ package git import ( "os" + "os/exec" "path/filepath" "strings" "testing" + + "github.com/aanogueira/git-context/internal/config" ) func TestNewGit(t *testing.T) { @@ -97,76 +100,6 @@ func TestBuildGitConfigWithQuotedSubsection(t *testing.T) { } } -func TestWriteConfigWithComplexStructure(t *testing.T) { - t.Parallel() - - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "test.gitconfig") - - g := NewGit(configPath) - - // Write config with nested sections - config := map[string]any{ - "user.name": "Test User", - "user.email": "test@example.com", - "user.signingkey": "ABCD1234", - "core.editor": "vim", - "core.autocrlf": "input", - "push.default": "simple", - "pull.rebase": "false", - `url "ssh://git@github.com/".insteadOf`: "https://github.com/", - } - - err := g.WriteConfig(config) - if err != nil { - t.Fatalf("WriteConfig failed: %v", err) - } - - // Read back and verify - content, err := os.ReadFile(configPath) - if err != nil { - t.Fatalf("Failed to read config: %v", err) - } - - contentStr := string(content) - - // Verify all sections and values - expectedPairs := []string{ - "[user]", - "name = Test User", - "email = test@example.com", - "signingkey = ABCD1234", - "[core]", - "editor = vim", - "autocrlf = input", - "[push]", - "default = simple", - "[pull]", - "rebase = false", - } - - for _, expected := range expectedPairs { - if !strings.Contains(contentStr, expected) { - t.Errorf("Config should contain '%s'", expected) - } - } -} - -func TestWriteConfigError(t *testing.T) { - t.Parallel() - - g := NewGit("/invalid/path/to/config") - - config := map[string]any{ - "user.name": "Test", - } - - err := g.WriteConfig(config) - if err == nil { - t.Error("WriteConfig should fail for invalid path") - } -} - func TestBackupConfigError(t *testing.T) { t.Parallel() @@ -281,3 +214,503 @@ func TestBuildGitConfigWithNestedDotNotation(t *testing.T) { t.Error("Config should contain nested section") } } + +func TestWriteProfileFile(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "work.gitconfig") + + g := NewGit(filepath.Join(tmpDir, ".gitconfig")) + + settings := map[string]any{ + "user.name": "Andre", + "user.email": "andre@work.com", + } + + if err := g.WriteProfileFile(target, settings); err != nil { + t.Fatalf("WriteProfileFile error: %v", err) + } + + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + + content := string(data) + + if !strings.Contains(content, "[user]") { + t.Errorf("missing [user] section in:\n%s", content) + } + + if !strings.Contains(content, "name = Andre") { + t.Errorf("missing user.name in:\n%s", content) + } + + if !strings.Contains(content, "email = andre@work.com") { + t.Errorf("missing user.email in:\n%s", content) + } +} + +func TestWriteProfileFileAtomic(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "work.gitconfig") + + // Pre-create the file with old content to verify replace, not append. + if err := os.WriteFile(target, []byte("OLD\n"), 0o644); err != nil { + t.Fatalf("setup write failed: %v", err) + } + + g := NewGit(filepath.Join(tmpDir, ".gitconfig")) + + if err := g.WriteProfileFile(target, map[string]any{"user.name": "New"}); err != nil { + t.Fatalf("WriteProfileFile error: %v", err) + } + + data, _ := os.ReadFile(target) + if strings.Contains(string(data), "OLD") { + t.Errorf("old content not replaced:\n%s", data) + } + + // No leftover .tmp file. + matches, _ := filepath.Glob(filepath.Join(tmpDir, "*.tmp")) + if len(matches) > 0 { + t.Errorf("temp files left behind: %v", matches) + } +} + +func TestWriteProfileFileWriteFailure(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "missing-subdir", "work.gitconfig") + + g := NewGit(filepath.Join(tmpDir, ".gitconfig")) + + err := g.WriteProfileFile(target, map[string]any{"user.name": "X"}) + if err == nil { + t.Fatal("expected error writing into nonexistent directory, got nil") + } + + if !strings.Contains(err.Error(), "failed to write temp file") { + t.Errorf("error = %q, want it to wrap 'failed to write temp file'", err.Error()) + } +} + +func TestWriteRootConfigDefaultAndAssignments(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, ".gitconfig") + g := NewGit(rootPath) + + defaultProfilePath := filepath.Join(tmpDir, "profiles", "work.gitconfig") + assignments := map[string]string{ + "/Users/x/projects/personal/": filepath.Join(tmpDir, "profiles", "personal.gitconfig"), + "/Users/x/Mollie/": filepath.Join(tmpDir, "profiles", "work.gitconfig"), + } + + if err := g.WriteRootConfig(defaultProfilePath, assignments); err != nil { + t.Fatalf("WriteRootConfig error: %v", err) + } + + data, err := os.ReadFile(rootPath) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + + content := string(data) + + if !strings.Contains(content, "Generated by git-context") { + t.Errorf("missing header marker:\n%s", content) + } + + if !strings.Contains(content, "[include]") { + t.Errorf("missing [include] block:\n%s", content) + } + + if !strings.Contains(content, "path = "+defaultProfilePath) { + t.Errorf("missing default profile path:\n%s", content) + } + + if !strings.Contains(content, `[includeIf "gitdir:/Users/x/Mollie/"]`) { + t.Errorf("missing Mollie includeIf:\n%s", content) + } + + if !strings.Contains(content, `[includeIf "gitdir:/Users/x/projects/personal/"]`) { + t.Errorf("missing personal includeIf:\n%s", content) + } +} + +func TestWriteRootConfigDeterministicOrder(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, ".gitconfig") + g := NewGit(rootPath) + + assignments := map[string]string{ + "/zzz/": "/profiles/c.gitconfig", + "/aaa/": "/profiles/a.gitconfig", + "/mmm/": "/profiles/b.gitconfig", + } + + if err := g.WriteRootConfig("", assignments); err != nil { + t.Fatalf("WriteRootConfig error: %v", err) + } + + data, _ := os.ReadFile(rootPath) + content := string(data) + + idxA := strings.Index(content, "gitdir:/aaa/") + idxM := strings.Index(content, "gitdir:/mmm/") + idxZ := strings.Index(content, "gitdir:/zzz/") + + if idxA >= idxM || idxM >= idxZ { + t.Errorf("includeIf blocks not sorted: aaa=%d mmm=%d zzz=%d", idxA, idxM, idxZ) + } +} + +func TestWriteRootConfigNoDefaultNoAssignments(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, ".gitconfig") + g := NewGit(rootPath) + + if err := g.WriteRootConfig("", map[string]string{}); err != nil { + t.Fatalf("WriteRootConfig error: %v", err) + } + + if _, err := os.Stat(rootPath); !os.IsNotExist(err) { + t.Errorf("expected no file written, got err=%v", err) + } +} + +func TestWriteRootConfigParseableByGit(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, ".gitconfig") + g := NewGit(rootPath) + + defaultPath := filepath.Join(tmpDir, "default.gitconfig") + assignments := map[string]string{"/Users/x/work/": filepath.Join(tmpDir, "work.gitconfig")} + + if err := g.WriteRootConfig(defaultPath, assignments); err != nil { + t.Fatalf("WriteRootConfig error: %v", err) + } + + cmd := exec.Command("git", "config", "-f", rootPath, "--list") + + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git config rejected file: %v\n%s", err, out) + } +} + +func TestRegenerate(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + profilesDir := filepath.Join(tmpDir, "profiles") + + if err := os.MkdirAll(profilesDir, 0o755); err != nil { + t.Fatalf("mkdir profiles: %v", err) + } + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{ + User: config.UserConfig{Name: "Andre", Email: "a@work.com"}, + Directories: []string{"/Users/x/Mollie/"}, + } + cfg.Profiles["personal"] = &config.Profile{ + User: config.UserConfig{Name: "Andre", Email: "a@home.com"}, + Directories: []string{"/Users/x/projects/personal/"}, + } + cfg.Current = "work" + + rootPath := filepath.Join(tmpDir, ".gitconfig") + g := NewGit(rootPath) + + if err := g.Regenerate(cfg, profilesDir); err != nil { + t.Fatalf("Regenerate error: %v", err) + } + + // Per-profile files exist with expected content. + for name, wantEmail := range map[string]string{"work": "a@work.com", "personal": "a@home.com"} { + path := filepath.Join(profilesDir, name+".gitconfig") + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + + if !strings.Contains(string(data), "email = "+wantEmail) { + t.Errorf("%s missing email=%s\n%s", name, wantEmail, data) + } + } + + // Root manifest references the right files. + root, err := os.ReadFile(rootPath) + if err != nil { + t.Fatalf("read root: %v", err) + } + + rootStr := string(root) + wantInclude := filepath.Join(profilesDir, "work.gitconfig") + + if !strings.Contains(rootStr, "path = "+wantInclude) { + t.Errorf("root missing default include for %q:\n%s", wantInclude, rootStr) + } + + if !strings.Contains(rootStr, `gitdir:/Users/x/Mollie/`) { + t.Errorf("root missing Mollie includeIf:\n%s", rootStr) + } + + if !strings.Contains(rootStr, `gitdir:/Users/x/projects/personal/`) { + t.Errorf("root missing personal includeIf:\n%s", rootStr) + } +} + +func TestRegenerateNoOpWhenNothingToWrite(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, ".gitconfig") + profilesDir := filepath.Join(tmpDir, "profiles") + + if err := os.MkdirAll(profilesDir, 0o755); err != nil { + t.Fatalf("mkdir profiles: %v", err) + } + + cfg := config.NewConfig() + cfg.Profiles["work"] = &config.Profile{User: config.UserConfig{Name: "X"}} + // No Current, no Directories — nothing to write to root manifest. + + g := NewGit(rootPath) + + if err := g.Regenerate(cfg, profilesDir); err != nil { + t.Fatalf("Regenerate error: %v", err) + } + + if _, err := os.Stat(rootPath); !os.IsNotExist(err) { + t.Errorf("expected no root manifest, got err=%v", err) + } + + // Per-profile file still gets written, regardless of whether root manifest is. + if _, err := os.Stat(filepath.Join(profilesDir, "work.gitconfig")); err != nil { + t.Errorf("expected work.gitconfig to exist: %v", err) + } +} + +func TestBackupConfigSkipsGeneratedManifest(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + src := filepath.Join(tmpDir, ".gitconfig") + dst := filepath.Join(tmpDir, ".gitconfig.bak") + + // Simulate a git-context-generated source by writing the header marker. + manifest := rootConfigHeader + "\n[include]\n\tpath = /tmp/x\n" + if err := os.WriteFile(src, []byte(manifest), 0o644); err != nil { + t.Fatalf("setup: %v", err) + } + + g := NewGit(src) + + if err := g.BackupConfig(dst); err != nil { + t.Fatalf("BackupConfig error: %v", err) + } + + if _, err := os.Stat(dst); !os.IsNotExist(err) { + t.Errorf("expected NO backup file when source is a generated manifest, got err=%v", err) + } +} + +func TestBackupConfigCopiesUserContent(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + src := filepath.Join(tmpDir, ".gitconfig") + dst := filepath.Join(tmpDir, ".gitconfig.bak") + + if err := os.WriteFile(src, []byte("[user]\n\tname = Old\n"), 0o644); err != nil { + t.Fatalf("setup: %v", err) + } + + g := NewGit(src) + + if err := g.BackupConfig(dst); err != nil { + t.Fatalf("BackupConfig error: %v", err) + } + + data, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("read backup: %v", err) + } + + if !strings.Contains(string(data), "name = Old") { + t.Errorf("backup missing original content:\n%s", data) + } +} + +func TestProfileToFlatConfigCoreFields(t *testing.T) { + t.Parallel() + + p := &config.Profile{ + User: config.UserConfig{ + Name: "Andre", + Email: "a@x.com", + SigningKey: "ABC123", + }, + } + + got := profileToFlatConfig(p) + + if got["user.name"] != "Andre" { + t.Errorf("user.name = %v, want Andre", got["user.name"]) + } + + if got["user.email"] != "a@x.com" { + t.Errorf("user.email = %v, want a@x.com", got["user.email"]) + } + + if got["user.signingkey"] != "ABC123" { + t.Errorf("user.signingkey = %v, want ABC123", got["user.signingkey"]) + } +} + +func TestProfileToFlatConfigOmitsEmptyUserFields(t *testing.T) { + t.Parallel() + + p := &config.Profile{User: config.UserConfig{Name: "Andre"}} + got := profileToFlatConfig(p) + + if _, ok := got["user.email"]; ok { + t.Error("user.email should not be set when empty") + } + + if _, ok := got["user.signingkey"]; ok { + t.Error("user.signingkey should not be set when empty") + } +} + +func TestProfileToFlatConfigURLRewrites(t *testing.T) { + t.Parallel() + + p := &config.Profile{ + URL: []config.URLConfig{ + {Pattern: "ssh://git@github.com/", InsteadOf: "https://github.com/"}, + }, + } + + got := profileToFlatConfig(p) + key := `url "ssh://git@github.com/".insteadOf` + + if got[key] != "https://github.com/" { + t.Errorf("URL rewrite key %q = %v, want https://github.com/", key, got[key]) + } +} + +func TestProfileToFlatConfigNestedSection(t *testing.T) { + t.Parallel() + + p := &config.Profile{ + Delta: map[string]any{ + "decorations": map[string]any{ + "file-style": "bold yellow", + }, + }, + } + + got := profileToFlatConfig(p) + + if got["delta.decorations.file-style"] != "bold yellow" { + t.Errorf("delta.decorations.file-style = %v, want 'bold yellow'\nfull map: %#v", + got["delta.decorations.file-style"], got) + } +} + +func TestFlattenIntoLeafValues(t *testing.T) { + t.Parallel() + + out := make(map[string]any) + flattenInto(out, "core", map[string]any{ + "editor": "nvim", + "autocrlf": "input", + }) + + if out["core.editor"] != "nvim" { + t.Errorf("core.editor = %v", out["core.editor"]) + } + + if out["core.autocrlf"] != "input" { + t.Errorf("core.autocrlf = %v", out["core.autocrlf"]) + } +} + +func TestFlattenIntoRecursesNestedMaps(t *testing.T) { + t.Parallel() + + out := make(map[string]any) + flattenInto(out, "delta", map[string]any{ + "decorations": map[string]any{ + "hunk-header-style": "syntax bold", + }, + "interactive": map[string]any{ + "keep-plus-minus-markers": true, + }, + }) + + if out["delta.decorations.hunk-header-style"] != "syntax bold" { + t.Errorf("missing nested key, got map: %#v", out) + } + + if out["delta.interactive.keep-plus-minus-markers"] != true { + t.Errorf("missing nested bool, got map: %#v", out) + } +} + +func TestWriteRootConfigNormalizesBackslashes(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + rootPath := filepath.Join(tmpDir, ".gitconfig") + g := NewGit(rootPath) + + // Simulate Windows-style absolute paths even on POSIX hosts. + defaultPath := `C:\Users\runner\AppData\profiles\work.gitconfig` + assignments := map[string]string{ + `C:\Users\runner\Mollie\`: `C:\Users\runner\AppData\profiles\work.gitconfig`, + } + + if err := g.WriteRootConfig(defaultPath, assignments); err != nil { + t.Fatalf("WriteRootConfig error: %v", err) + } + + data, err := os.ReadFile(rootPath) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + + content := string(data) + + if strings.Contains(content, `\`) { + t.Errorf("manifest contains backslash; git config would reject:\n%s", content) + } + + if !strings.Contains(content, "C:/Users/runner/AppData/profiles/work.gitconfig") { + t.Errorf("path not slash-normalized:\n%s", content) + } + + if !strings.Contains(content, `gitdir:C:/Users/runner/Mollie/`) { + t.Errorf("gitdir not slash-normalized:\n%s", content) + } +}