From 512ce70933c3318871eeb156094afe15ac1897cf Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Mon, 4 May 2026 14:13:17 +0100 Subject: [PATCH 01/28] docs: add design spec for directory-based profile assignment Captures the includeIf-based approach: per-profile gitconfig files materialized to ~/.config/git-context/profiles/, with ~/.gitconfig becoming a thin manifest of [include] + [includeIf gitdir:...] blocks that git-context regenerates on every mutating command. Signed-off-by: Andre Nogueira --- ...rectory-based-profile-assignment-design.md | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-04-directory-based-profile-assignment-design.md 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 From 608b3a8d6951a80940aa26dab03d9653ffd26b80 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Mon, 4 May 2026 14:20:24 +0100 Subject: [PATCH 02/28] docs: add implementation plan for directory-based profile assignment 17 TDD-structured tasks covering config schema changes, atomic file writers, the new `dir` subcommand, and updates to existing commands. End-to-end test verifies git resolves the right user.email inside and outside assigned directories. Signed-off-by: Andre Nogueira --- ...5-04-directory-based-profile-assignment.md | 2616 +++++++++++++++++ 1 file changed, 2616 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-04-directory-based-profile-assignment.md 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 From 8418095a12396c7f0abb77f948fadfec38c659f1 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Mon, 4 May 2026 14:59:56 +0100 Subject: [PATCH 03/28] feat(config): add Directories field to Profile Signed-off-by: Andre Nogueira --- internal/config/config.go | 1 + internal/config/config_test.go | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index efd343f..295db7d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,6 +37,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"` } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2fd51b5..0267340 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -587,3 +587,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]) + } + } +} From a9b26b04eb73eb21896b0198c7d04f249dce7be7 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Mon, 4 May 2026 15:03:19 +0100 Subject: [PATCH 04/28] feat(config): add NormalizeDir for includeIf path handling Signed-off-by: Andre Nogueira --- internal/config/dirs.go | 53 +++++++++++++++++++++++++++++++ internal/config/dirs_test.go | 61 ++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 internal/config/dirs.go create mode 100644 internal/config/dirs_test.go diff --git a/internal/config/dirs.go b/internal/config/dirs.go new file mode 100644 index 0000000..931ee91 --- /dev/null +++ b/internal/config/dirs.go @@ -0,0 +1,53 @@ +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 +} diff --git a/internal/config/dirs_test.go b/internal/config/dirs_test.go new file mode 100644 index 0000000..30ce7cc --- /dev/null +++ b/internal/config/dirs_test.go @@ -0,0 +1,61 @@ +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") + } +} From d044cb531c67fc9c9ec470438d749f85ea7aaa1d Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Mon, 4 May 2026 15:07:05 +0100 Subject: [PATCH 05/28] feat(config): add LookupDir and AssignmentMap Signed-off-by: Andre Nogueira --- internal/config/dirs.go | 28 +++++++++++++++++++ internal/config/dirs_test.go | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/internal/config/dirs.go b/internal/config/dirs.go index 931ee91..2cfa15d 100644 --- a/internal/config/dirs.go +++ b/internal/config/dirs.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "slices" "strings" "github.com/cockroachdb/errors" @@ -51,3 +52,30 @@ func NormalizeDir(path string) (string, error) { 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 +} diff --git a/internal/config/dirs_test.go b/internal/config/dirs_test.go index 30ce7cc..769098b 100644 --- a/internal/config/dirs_test.go +++ b/internal/config/dirs_test.go @@ -59,3 +59,57 @@ func TestNormalizeDirEmpty(t *testing.T) { 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) + } + } +} From a3fdbd28d81451e77793a0745d4f50c65fa775e3 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Mon, 4 May 2026 15:10:36 +0100 Subject: [PATCH 06/28] feat(config): add AssignDir/UnassignDir with conflict detection Signed-off-by: Andre Nogueira --- internal/config/dirs.go | 50 +++++++++++++++++++++ internal/config/dirs_test.go | 86 ++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/internal/config/dirs.go b/internal/config/dirs.go index 2cfa15d..35bd4b2 100644 --- a/internal/config/dirs.go +++ b/internal/config/dirs.go @@ -79,3 +79,53 @@ func (c *Config) AssignmentMap() map[string]string { 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 index 769098b..87d60c1 100644 --- a/internal/config/dirs_test.go +++ b/internal/config/dirs_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -113,3 +114,88 @@ func TestAssignmentMap(t *testing.T) { } } } + +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") + } +} From 085dd7bf2dcda5ad0e73d3c4cd772cec4318e822 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Mon, 4 May 2026 15:14:01 +0100 Subject: [PATCH 07/28] feat(config): add ProfilesDir to Paths Signed-off-by: Andre Nogueira --- internal/config/paths.go | 8 +++++++- internal/config/paths_test.go | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) 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) + } +} From bbfb1664b56cfe8d269de82dde1402438bc0a198 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Mon, 4 May 2026 15:17:09 +0100 Subject: [PATCH 08/28] feat(git): add atomic WriteProfileFile Signed-off-by: Andre Nogueira --- internal/git/git.go | 24 +++++++++++++++ internal/git/git_test.go | 66 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/internal/git/git.go b/internal/git/git.go index 403b5f1..fcb5d8f 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -48,6 +48,30 @@ func (g *Git) BackupConfig(backupPath string) error { return nil } +// 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 +} + // 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..5aab4b7 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -281,3 +281,69 @@ 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) + } +} From 55781c302dd6ff4b1bf9af06df9c284ef7d35bf2 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Mon, 4 May 2026 15:20:34 +0100 Subject: [PATCH 09/28] fix(git): clarify WriteProfileFile/atomicWrite docs, cover write-failure path Signed-off-by: Andre Nogueira --- internal/git/git.go | 10 ++++++++-- internal/git/git_test.go | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/git/git.go b/internal/git/git.go index fcb5d8f..92ee48c 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -48,8 +48,11 @@ func (g *Git) BackupConfig(backupPath string) error { return nil } -// WriteProfileFile writes a flat key→value git config map to `path` -// using atomic temp-file-and-rename semantics. +// 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) @@ -57,6 +60,9 @@ func (g *Git) WriteProfileFile(path string, config map[string]any) error { } // 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 { diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 5aab4b7..4732a73 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -347,3 +347,21 @@ func TestWriteProfileFileAtomic(t *testing.T) { 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()) + } +} From a7fd2225c47e59cc830214cc9ef500b63dab9e9a Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Mon, 4 May 2026 15:23:51 +0100 Subject: [PATCH 10/28] feat(git): add WriteRootConfig manifest writer Signed-off-by: Andre Nogueira --- internal/git/git.go | 47 ++++++++++++++++ internal/git/git_test.go | 117 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/internal/git/git.go b/internal/git/git.go index 92ee48c..d87680a 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -3,6 +3,7 @@ package git import ( "fmt" "os" + "sort" "strings" "github.com/cockroachdb/errors" @@ -78,6 +79,52 @@ func atomicWrite(path string, data []byte) error { return nil } +// 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())) +} + // 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 4732a73..1caf0fd 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -2,6 +2,7 @@ package git import ( "os" + "os/exec" "path/filepath" "strings" "testing" @@ -365,3 +366,119 @@ func TestWriteProfileFileWriteFailure(t *testing.T) { 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) + } +} From a69849ce6ae00545c5e1b966823eb1b5226b64b8 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Mon, 4 May 2026 15:29:17 +0100 Subject: [PATCH 11/28] feat(git): add Regenerate orchestrator Signed-off-by: Andre Nogueira --- internal/git/git.go | 81 +++++++++++++++++++++++++++++++++ internal/git/git_test.go | 97 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) diff --git a/internal/git/git.go b/internal/git/git.go index d87680a..ee3a8f4 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -3,9 +3,11 @@ package git import ( "fmt" "os" + "path/filepath" "sort" "strings" + "github.com/aanogueira/git-context/internal/config" "github.com/cockroachdb/errors" ) @@ -125,6 +127,85 @@ func (g *Git) WriteRootConfig(defaultProfilePath string, assignments map[string] 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. +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 + } + } +} + // 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 1caf0fd..5b16b37 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/aanogueira/git-context/internal/config" ) func TestNewGit(t *testing.T) { @@ -482,3 +484,98 @@ func TestWriteRootConfigParseableByGit(t *testing.T) { 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) + } +} From 7d8cf17dbb8cbf4e3aa93615e967143155f11ed3 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 00:10:47 +0100 Subject: [PATCH 12/28] docs(git): document Regenerate's non-transactional failure semantics Signed-off-by: Andre Nogueira --- internal/git/git.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/git/git.go b/internal/git/git.go index ee3a8f4..657be4c 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -136,6 +136,12 @@ func (g *Git) WriteRootConfig(defaultProfilePath string, assignments map[string] // // 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) From c028bc72a3256d3af614e69d51db965f2bf9aeea Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 00:13:17 +0100 Subject: [PATCH 13/28] refactor(switch): write via Regenerate, emit manifest Signed-off-by: Andre Nogueira --- cmd/cmd_test.go | 220 ------------------------------------------------ cmd/switch.go | 89 ++------------------ 2 files changed, 6 insertions(+), 303 deletions(-) diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 670880e..bcbc775 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -364,99 +364,6 @@ func TestShowProfile(t *testing.T) { }) } -func TestProfileToGitConfig(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 user config - if gitConfig["user.name"] != "Test User" { - t.Error("Git config should contain user.name") - } - - if gitConfig["user.email"] != "test@example.com" { - t.Error("Git config should contain user.email") - } - - if gitConfig["user.signingkey"] != "ABCD1234" { - t.Error("Git config should contain user.signingkey") - } - - // Verify URL rewrite - urlKey := `url "ssh://git@github.com/".insteadOf` - if gitConfig[urlKey] != "https://github.com/" { - t.Error("Git config should contain URL rewrite") - } - - // Verify other sections - if gitConfig["core.editor"] != "vim" { - t.Error("Git config should contain core.editor") - } - - if gitConfig["push.default"] != "simple" { - t.Error("Git config should contain push.default") - } -} - -func TestAddSectionToConfig(t *testing.T) { - t.Parallel() - - gitConfig := make(map[string]any) - - section := map[string]any{ - "editor": "vim", - "autocrlf": "input", - } - - addSectionToConfig(gitConfig, "core", section) - - if gitConfig["core.editor"] != "vim" { - t.Error("Should add core.editor") - } - - if gitConfig["core.autocrlf"] != "input" { - t.Error("Should add core.autocrlf") - } -} - -func TestAddSectionToConfigNested(t *testing.T) { - t.Parallel() - - gitConfig := make(map[string]any) - - section := map[string]any{ - "interactive": map[string]any{ - "diffFilter": "delta --color-only", - }, - } - - addSectionToConfig(gitConfig, "add", section) - - if gitConfig["add.interactive.diffFilter"] != "delta --color-only" { - t.Error("Should add nested config values") - } -} - func TestInitCommandExists(t *testing.T) { t.Parallel() @@ -491,130 +398,3 @@ func TestRootCommandMetadata(t *testing.T) { t.Error("Short description should be set") } } - -func TestProfileToGitConfigAllSections(t *testing.T) { - t.Parallel() - - 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", - }, - } - - gitConfig := profileToGitConfig(profile) - - // 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", - } - - for _, key := range sections { - if _, exists := gitConfig[key]; !exists { - t.Errorf("Git config should contain key: %s", key) - } - } -} - -func TestProfileToGitConfigEmptySections(t *testing.T) { - t.Parallel() - - profile := &config.Profile{ - User: config.UserConfig{ - Name: "Test", - Email: "test@test.com", - }, - // All other sections empty/nil - } - - gitConfig := profileToGitConfig(profile) - - // Should have user config - if gitConfig["user.name"] != "Test" { - t.Error("Should have user.name") - } - - // Other sections should not add keys - if val, exists := gitConfig["http.something"]; exists { - t.Errorf("Should not have http keys, got: %v", val) - } -} - -func TestAddSectionToConfigRecursiveDeepNesting(t *testing.T) { - t.Parallel() - - gitConfig := make(map[string]any) - - values := map[string]any{ - "level1": map[string]any{ - "level2": map[string]any{ - "level3": "deepvalue", - }, - }, - } - - addSectionToConfigRecursive(gitConfig, "section", values) - - if gitConfig["section.level1.level2.level3"] != "deepvalue" { - t.Error("Should handle deep nesting") - } -} 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) } From 06e6d811a8e0e1dc3e5cf2d75dd6cc7f30a96539 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 00:18:18 +0100 Subject: [PATCH 14/28] fix(git): preserve pre-migration backup; cover flatten helpers Signed-off-by: Andre Nogueira --- internal/git/git.go | 11 ++- internal/git/git_test.go | 168 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/internal/git/git.go b/internal/git/git.go index 657be4c..5110a79 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -1,6 +1,7 @@ package git import ( + "bytes" "fmt" "os" "path/filepath" @@ -33,7 +34,11 @@ func (g *Git) WriteConfig(config map[string]any) error { return nil } -// BackupConfig creates a backup of the git config. +// 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 { @@ -44,6 +49,10 @@ func (g *Git) BackupConfig(backupPath string) error { 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") } diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 5b16b37..06e8ba7 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -579,3 +579,171 @@ func TestRegenerateNoOpWhenNothingToWrite(t *testing.T) { 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) + } +} From 760e21ea165f67a10adc4e1f413aa0be90175027 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 00:22:45 +0100 Subject: [PATCH 15/28] feat(remove): regenerate manifest, prompt mentions assigned dirs Signed-off-by: Andre Nogueira --- cmd/cmd_test.go | 83 +++++++++++++++++++++++++++++++++++++++++++++ cmd/remove.go | 43 +++++++++++++++++++---- internal/git/git.go | 28 ++++++++++++++- 3 files changed, 147 insertions(+), 7 deletions(-) diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index bcbc775..56041c9 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -3,9 +3,12 @@ package cmd import ( "os" "path/filepath" + "strings" "testing" "github.com/aanogueira/git-context/internal/config" + "github.com/aanogueira/git-context/internal/git" + "github.com/cockroachdb/errors" ) func TestRunInit(t *testing.T) { @@ -228,6 +231,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() 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/internal/git/git.go b/internal/git/git.go index 5110a79..564ebf3 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -110,7 +110,7 @@ const rootConfigHeader = "# Generated by git-context — do not edit. " + // git-context state exists). func (g *Git) WriteRootConfig(defaultProfilePath string, assignments map[string]string) error { if defaultProfilePath == "" && len(assignments) == 0 { - return nil + return g.removeManagedManifest() } var b strings.Builder @@ -177,6 +177,32 @@ func (g *Git) Regenerate(cfg *config.Config, profilesDir string) error { 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 + } + + return errors.Wrap(err, "failed to read existing git config") + } + + 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 +} + // 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. From a542d25532f1ab1a12cf3777930e5dbbb6082c59 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 00:26:53 +0100 Subject: [PATCH 16/28] feat(list): show assigned-directory count column Signed-off-by: Andre Nogueira --- cmd/cmd_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ cmd/list.go | 16 +++++++++---- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 56041c9..be592b2 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "os" "path/filepath" "strings" @@ -466,6 +467,68 @@ func TestInitCommandExists(t *testing.T) { } } +func TestListProfilesShowsDirsColumn(t *testing.T) { + 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) + } +} + +// 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 +} + func TestRootCommandMetadata(t *testing.T) { t.Parallel() 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 } From f8f7daea983ec10fa76f00d44cd4ffa3c483d574 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 00:29:33 +0100 Subject: [PATCH 17/28] test(cmd): serialize captureStdout, document non-parallel constraint Signed-off-by: Andre Nogueira --- cmd/cmd_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index be592b2..46817a2 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "github.com/aanogueira/git-context/internal/config" @@ -499,10 +500,24 @@ func TestListProfilesShowsDirsColumn(t *testing.T) { } } +// 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) From 0d4329638905d99d6a33602748e1f1e496ad024c Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 00:31:41 +0100 Subject: [PATCH 18/28] feat(show): list assigned directories Signed-off-by: Andre Nogueira --- cmd/cmd_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ cmd/show.go | 9 +++++++++ 2 files changed, 51 insertions(+) diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 46817a2..8987697 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -11,6 +11,7 @@ import ( "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) { @@ -526,6 +527,11 @@ func captureStdout(t *testing.T, fn func()) string { old := os.Stdout os.Stdout = w + // 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 + color.Output = w + done := make(chan string) go func() { @@ -540,6 +546,7 @@ func captureStdout(t *testing.T, fn func()) string { _ = w.Close() os.Stdout = old + color.Output = oldColor return <-done } @@ -559,3 +566,38 @@ func TestRootCommandMetadata(t *testing.T) { t.Error("Short description should be set") } } + +func TestShowDisplaysDirectories(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{"/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) + } +} 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 } From 5ef99f613562f90a741543354195f06786e08da1 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 00:34:16 +0100 Subject: [PATCH 19/28] test(cmd): defer captureStdout restoration to survive panics Signed-off-by: Andre Nogueira --- cmd/cmd_test.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 8987697..9597608 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -525,13 +525,17 @@ func captureStdout(t *testing.T, fn func()) string { } old := os.Stdout - os.Stdout = w - // 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 + defer func() { + os.Stdout = old + color.Output = oldColor + }() + done := make(chan string) go func() { @@ -545,9 +549,6 @@ func captureStdout(t *testing.T, fn func()) string { _ = w.Close() - os.Stdout = old - color.Output = oldColor - return <-done } From b698c2c34f86ad0f40c7d3011f54328a1ed3f2bf Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 00:36:36 +0100 Subject: [PATCH 20/28] feat(current): show effective profile in cwd Signed-off-by: Andre Nogueira --- cmd/cmd_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++ cmd/current.go | 68 ++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 9597608..6231461 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "os" + "os/exec" "path/filepath" "strings" "sync" @@ -602,3 +603,61 @@ func TestShowDisplaysDirectories(t *testing.T) { t.Errorf("output missing assigned dir:\n%s", out) } } + +func TestCurrentShowsEffectiveProfileInCwd(t *testing.T) { + 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" + + 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) + } + + t.Chdir(repoDir) + + 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) + } +} diff --git a/cmd/current.go b/cmd/current.go index eb3604e..09b22bd 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,67 @@ 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 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) + } - return nil + if effective := effectiveProfileInCwd(paths, cfg); effective != "" { + ui.PrintInfo("Effective in " + currentDir() + ": " + effective) } - profile, err := cfg.GetProfile(cfg.Current) + 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 "" + } - return errors.Wrap(err, "failed to get active profile") + line := strings.TrimSpace(string(out)) + + 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) + origin := strings.TrimPrefix(parts[0], "file:") - return nil + 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() { From 3d54e160e5715d83155b40d2abcf9dbbe0fb874e Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 00:40:12 +0100 Subject: [PATCH 21/28] fix(current): require file: origin and resolve symlinks Signed-off-by: Andre Nogueira --- cmd/cmd_test.go | 26 ++++++++++++++++++++++++++ cmd/current.go | 20 ++++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 6231461..5e5b4b9 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -661,3 +661,29 @@ func TestCurrentShowsEffectiveProfileInCwd(t *testing.T) { 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 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 09b22bd..f48694e 100644 --- a/cmd/current.go +++ b/cmd/current.go @@ -77,10 +77,14 @@ func effectiveProfileInCwd(paths *config.Paths, cfg *config.Config) string { return "" } - origin := strings.TrimPrefix(parts[0], "file:") + if !strings.HasPrefix(parts[0], "file:") { + return "" + } + + origin := resolveSymlinks(strings.TrimPrefix(parts[0], "file:")) for name := range cfg.Profiles { - profilePath := filepath.Join(paths.ProfilesDir, name+".gitconfig") + profilePath := resolveSymlinks(filepath.Join(paths.ProfilesDir, name+".gitconfig")) if origin == profilePath { return name } @@ -89,6 +93,18 @@ func effectiveProfileInCwd(paths *config.Paths, cfg *config.Config) string { 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 { From f18b3ba3096328c800a4f9c71113ac02ed0d96b6 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 00:41:39 +0100 Subject: [PATCH 22/28] feat(add): optional directory-assignment prompt Signed-off-by: Andre Nogueira --- cmd/add.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) 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 From 92f8cb6052ec53d1a8da47a70bd35d7dd98b43b2 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 00:45:30 +0100 Subject: [PATCH 23/28] feat(dir): add 'dir add/remove/list' subcommands Signed-off-by: Andre Nogueira --- cmd/dir.go | 203 ++++++++++++++++++++++++++++++++++++++++++++++++ cmd/dir_test.go | 165 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 cmd/dir.go create mode 100644 cmd/dir_test.go 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) + } +} From c88db6fdf06eb43c7475fc3ef0eca2522e485654 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 01:24:29 +0100 Subject: [PATCH 24/28] fix(config): persist Current; remove brittle --global probe The previous determineCurrent() probed git config --global user.name to recover Current on each LoadConfig. After the manifest refactor in Task 9, --global doesn't follow [include] directives, so Current would always read as empty and any subsequent Regenerate would drop the default profile from the manifest. Persisting Current in config.yaml removes the side channel and makes the manifest faithfully reflect the configured default. Signed-off-by: Andre Nogueira --- internal/config/config.go | 39 +--------------- internal/config/config_test.go | 83 +++++++++++++--------------------- 2 files changed, 33 insertions(+), 89 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 295db7d..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" @@ -59,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"` } @@ -90,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 } @@ -209,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 0267340..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) { From d0a3228794069e1555f5ff165f2913aa1e0f5905 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 01:24:35 +0100 Subject: [PATCH 25/28] test: end-to-end directory-based profile lifecycle Signed-off-by: Andre Nogueira --- cmd/integration_test.go | 115 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 cmd/integration_test.go 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) + } +} From 2b7cf0bacea8672907cd1477ff5978982d3e487d Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 01:27:44 +0100 Subject: [PATCH 26/28] docs: document dir commands and new file layout Signed-off-by: Andre Nogueira --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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`. From b21553ea2a2ce3a63bc3ac0bcfc80f5bf0f854a5 Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 01:31:26 +0100 Subject: [PATCH 27/28] refactor(git): remove dead WriteConfig and stale doc reference Signed-off-by: Andre Nogueira --- internal/git/git.go | 17 +++------- internal/git/git_test.go | 70 ---------------------------------------- 2 files changed, 4 insertions(+), 83 deletions(-) diff --git a/internal/git/git.go b/internal/git/git.go index 564ebf3..14ec2f4 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -24,16 +24,6 @@ func NewGit(globalConfigPath string) *Git { } } -// WriteConfig writes configuration to git global config. -func (g *Git) WriteConfig(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 nil -} - // 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 @@ -203,9 +193,10 @@ func (g *Git) removeManagedManifest() error { return nil } -// 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. +// 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) diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 06e8ba7..18a6a2f 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -100,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() From c11f5daaf1b6b0130604bf3f5f66401029428adb Mon Sep 17 00:00:00 2001 From: Andre Nogueira Date: Tue, 5 May 2026 11:11:11 +0100 Subject: [PATCH 28/28] fix(git): normalize manifest paths to forward slashes for Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git config rejects backslashes in unquoted 'path =' values, causing WriteRootConfig output to fail on Windows CI. Pipe defaultProfilePath, gitdir keys, and includeIf paths through a small toGitPath helper that unconditionally replaces backslashes with forward slashes — filepath.ToSlash is insufficient because it only swaps the OS-specific separator and is a no-op on POSIX, which would leave the regression test (using literal Windows-shaped paths) failing on macOS/Linux. Signed-off-by: Andre Nogueira --- internal/git/git.go | 20 ++++++++++++++++++-- internal/git/git_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/internal/git/git.go b/internal/git/git.go index 14ec2f4..24a83d3 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -98,6 +98,10 @@ const rootConfigHeader = "# Generated by git-context — do not edit. " + // 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() @@ -109,7 +113,7 @@ func (g *Git) WriteRootConfig(defaultProfilePath string, assignments map[string] b.WriteString("\n") if defaultProfilePath != "" { - fmt.Fprintf(&b, "[include]\n\tpath = %s\n\n", defaultProfilePath) + fmt.Fprintf(&b, "[include]\n\tpath = %s\n\n", toGitPath(defaultProfilePath)) } keys := make([]string, 0, len(assignments)) @@ -120,7 +124,8 @@ func (g *Git) WriteRootConfig(defaultProfilePath string, assignments map[string] sort.Strings(keys) for _, dir := range keys { - fmt.Fprintf(&b, "[includeIf \"gitdir:%s\"]\n\tpath = %s\n\n", dir, assignments[dir]) + fmt.Fprintf(&b, "[includeIf \"gitdir:%s\"]\n\tpath = %s\n\n", + toGitPath(dir), toGitPath(assignments[dir])) } return atomicWrite(g.globalConfigPath, []byte(b.String())) @@ -193,6 +198,17 @@ func (g *Git) removeManagedManifest() error { 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 diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 18a6a2f..40b92f0 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -677,3 +677,40 @@ func TestFlattenIntoRecursesNestedMaps(t *testing.T) { 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) + } +}