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