Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@
# vendor/

gitjoin

.claude/
118 changes: 98 additions & 20 deletions internal/lib/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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)
}
56 changes: 45 additions & 11 deletions testscripts/default.txt
Original file line number Diff line number Diff line change
@@ -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 .
Expand All @@ -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
Expand All @@ -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 .
Expand All @@ -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
Expand All @@ -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
Loading