From d49fc4af1684d61894871b5923e6a63a14d0a099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Tue, 12 May 2026 22:11:56 +0200 Subject: [PATCH] Manage .gitignore per gitjoin.txt directory Each gitjoin.txt now updates only its sibling .gitignore, listing its repos by basename. Stale managed sections in .gitignore files whose directory has no gitjoin.txt are pruned on sync, which both fixes the migration from the old root-level layout and makes runs idempotent regardless of which subdirectory gitjoin is invoked from. --- .gitignore | 2 + internal/lib/sync.go | 118 +++++++++++++++++++++++++++++++++------- testscripts/default.txt | 56 +++++++++++++++---- 3 files changed, 145 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 7c9acca..95edc09 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ # vendor/ gitjoin + +.claude/ \ No newline at end of file diff --git a/internal/lib/sync.go b/internal/lib/sync.go index 05c1fc2..83306bb 100644 --- a/internal/lib/sync.go +++ b/internal/lib/sync.go @@ -96,7 +96,11 @@ func (s *Syncer) run() (Result, error) { var mu sync.Mutex var existing sync.Map - expected, err := s.collectExpectedRepos() + sources, err := s.collectSources() + if err != nil { + return result, err + } + expected, err := s.expectedFromSources(sources) if err != nil { return result, err } @@ -129,7 +133,7 @@ func (s *Syncer) run() (Result, error) { } } - if err := s.updateGitignore(expected); err != nil { + if err := s.updateGitignores(sources); err != nil { return result, fmt.Errorf("update .gitignore: %w", err) } @@ -255,8 +259,13 @@ func (s *Syncer) processRepo(ctx context.Context, localPath, repoPath string, ex return nil } -func (s *Syncer) collectExpectedRepos() (map[string]string, error) { - expected := make(map[string]string) +type gitjoinSource struct { + dir string + repos []string +} + +func (s *Syncer) collectSources() ([]gitjoinSource, error) { + var sources []gitjoinSource err := filepath.WalkDir(s.Cfg.Root, func(path string, d os.DirEntry, err error) error { if err != nil { @@ -280,19 +289,29 @@ func (s *Syncer) collectExpectedRepos() (map[string]string, error) { return err } - for _, repo := range repos { + sources = append(sources, gitjoinSource{dir: relDir, repos: repos}) + return nil + }) + + return sources, err +} + +func (s *Syncer) expectedFromSources(sources []gitjoinSource) (map[string]string, error) { + expected := make(map[string]string) + for _, src := range sources { + for _, repo := range src.repos { repoName := filepath.Base(repo) var localPath string - if relDir == "." { + if src.dir == "." { localPath = repoName } else { - localPath = filepath.Join(relDir, repoName) + localPath = filepath.Join(src.dir, repoName) } if s.Cfg.Paths != "" { matched, err := filepath.Match(s.Cfg.Paths, localPath) if err != nil { - return err + return nil, err } if !matched { continue @@ -301,10 +320,8 @@ func (s *Syncer) collectExpectedRepos() (map[string]string, error) { expected[localPath] = repo } - return nil - }) - - return expected, err + } + return expected, nil } func (s *Syncer) findAllGitRepos() ([]string, error) { @@ -363,19 +380,30 @@ const ( gitignoreEnd = "# End gitjoin managed section" ) -func (s *Syncer) updateGitignore(repos map[string]string) error { - gitignorePath := filepath.Join(s.Cfg.Root, ".gitignore") +func (s *Syncer) updateGitignores(sources []gitjoinSource) error { + managedDirs := make(map[string]bool) + for _, src := range sources { + managedDirs[src.dir] = true + if err := s.writeManagedSection(src); err != nil { + return err + } + } + return s.pruneStaleManaged(managedDirs) +} + +func (s *Syncer) writeManagedSection(src gitjoinSource) error { + gitignorePath := filepath.Join(s.Cfg.Root, src.dir, ".gitignore") - var paths []string - for localPath := range repos { - paths = append(paths, filepath.ToSlash(localPath)+"/") + var entries []string + for _, repo := range src.repos { + entries = append(entries, filepath.Base(repo)+"/") } - sort.Strings(paths) + sort.Strings(entries) var managed strings.Builder managed.WriteString(gitignoreStart + "\n") - for _, p := range paths { - managed.WriteString(p + "\n") + for _, e := range entries { + managed.WriteString(e + "\n") } managed.WriteString(gitignoreEnd + "\n") @@ -408,3 +436,53 @@ func (s *Syncer) updateGitignore(repos map[string]string) error { return os.WriteFile(gitignorePath, []byte(newContent), 0o644) } + +func (s *Syncer) pruneStaleManaged(managedDirs map[string]bool) error { + return filepath.WalkDir(s.Cfg.Root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && d.Name() == ".git" { + return filepath.SkipDir + } + if d.IsDir() || d.Name() != ".gitignore" { + return nil + } + relDir, err := filepath.Rel(s.Cfg.Root, filepath.Dir(path)) + if err != nil { + return err + } + if managedDirs[relDir] { + return nil + } + return removeManagedSection(path) + }) +} + +func removeManagedSection(gitignorePath string) error { + existing, err := os.ReadFile(gitignorePath) + if err != nil { + return err + } + content := string(existing) + startIdx := strings.Index(content, gitignoreStart) + endIdx := strings.Index(content, gitignoreEnd) + if startIdx < 0 || endIdx <= startIdx { + return nil + } + endIdx += len(gitignoreEnd) + if endIdx < len(content) && content[endIdx] == '\n' { + endIdx++ + } + for startIdx > 0 && content[startIdx-1] == '\n' { + startIdx-- + } + result := content[:startIdx] + content[endIdx:] + if strings.TrimSpace(result) == "" { + return os.Remove(gitignorePath) + } + if !strings.HasSuffix(result, "\n") { + result += "\n" + } + return os.WriteFile(gitignorePath, []byte(result), 0o644) +} diff --git a/testscripts/default.txt b/testscripts/default.txt index ee28a73..e368cf8 100644 --- a/testscripts/default.txt +++ b/testscripts/default.txt @@ -1,8 +1,9 @@ dostounix golden/tree1.txt dostounix golden/tree2.txt -dostounix golden/gitignore1.txt -dostounix golden/gitignore2.txt +dostounix golden/libs-gitignore1.txt +dostounix golden/main-gitignore2.txt +dostounix golden/libs-gitignore-empty.txt gitjoin ! stdout . @@ -11,7 +12,10 @@ stderr 'Cloned: 2 repos' ! stderr 'Updated' ! stderr 'Warnings' -cmp .gitignore golden/gitignore1.txt +# .gitignore is written next to the gitjoin.txt that lists the repos, +# not at the workspace root. +! exists .gitignore +cmp gocode/libs/.gitignore golden/libs-gitignore1.txt tree gocode cp stdout gocodetree.txt @@ -24,8 +28,10 @@ stderr 'Skipped.*1 repos' ! stderr 'Cloned' ! stderr 'Updated' +# Creating a new folder with a gitjoin.txt should create a sibling .gitignore. mkdir gocode/main cp testdata/gitjoin1.txt gocode/main/gitjoin.txt +! exists gocode/main/.gitignore gitjoin ! stdout . @@ -34,11 +40,32 @@ stderr 'Cloned: 1 repos' stderr 'Skipped.*1 repos' ! stderr 'Updated' +exists gocode/main/.gitignore + tree gocode cp stdout gocodetree.txt cmp gocodetree.txt golden/tree2.txt -cmp .gitignore golden/gitignore2.txt +! exists .gitignore +cmp gocode/libs/.gitignore golden/libs-gitignore1.txt +cmp gocode/main/.gitignore golden/main-gitignore2.txt + +# A .gitignore with a stale managed section (no sibling gitjoin.txt) should be +# pruned on the next sync. +cp testdata/stale-gitignore.txt .gitignore +exists .gitignore + +# Emptying gocode/libs/gitjoin.txt removes its repos and leaves an empty +# managed section in the sibling .gitignore (the gitjoin.txt is still there). +cp testdata/empty-gitjoin.txt gocode/libs/gitjoin.txt + +gitjoin +! stdout . +stderr 'Removed: 2 repos' + +! exists .gitignore +cmp gocode/libs/.gitignore golden/libs-gitignore-empty.txt +cmp gocode/main/.gitignore golden/main-gitignore2.txt -- gocode/libs/gitjoin.txt -- github.com/bep/lazycache @@ -57,14 +84,21 @@ github.com/bep/debounce └─git:firstupdotenv/ -- testdata/gitjoin1.txt -- github.com/bep/firstupdotenv --- golden/gitignore1.txt -- +-- testdata/empty-gitjoin.txt -- +# no repos +-- testdata/stale-gitignore.txt -- +# Managed by gitjoin - do not edit this section +something/stale/ +# End gitjoin managed section +-- golden/libs-gitignore1.txt -- +# Managed by gitjoin - do not edit this section +debounce/ +lazycache/ +# End gitjoin managed section +-- golden/main-gitignore2.txt -- # Managed by gitjoin - do not edit this section -gocode/libs/debounce/ -gocode/libs/lazycache/ +firstupdotenv/ # End gitjoin managed section --- golden/gitignore2.txt -- +-- golden/libs-gitignore-empty.txt -- # Managed by gitjoin - do not edit this section -gocode/libs/debounce/ -gocode/libs/lazycache/ -gocode/main/firstupdotenv/ # End gitjoin managed section