This repository has no description
1package git
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "sync"
12
13 "github.com/hashicorp/go-version"
14)
15
16// repoLocks serializes git operations per repo directory. Concurrent triggers
17// on the same repo (a push landing while a manual run is dispatched, two "Run
18// CI" clicks, etc.) resolve to the same path with different revisions; running
19// clone/fetch/checkout there in parallel collides on .git/index.lock and can
20// corrupt the dir. Locking is keyed by path so unrelated repos don't serialize.
21var repoLocks keyedMutex
22
23type keyedMutex struct {
24 mu sync.Mutex
25 m map[string]*sync.Mutex
26}
27
28// lock acquires the mutex for key and returns its unlock func.
29func (k *keyedMutex) lock(key string) func() {
30 k.mu.Lock()
31 if k.m == nil {
32 k.m = make(map[string]*sync.Mutex)
33 }
34 mu, ok := k.m[key]
35 if !ok {
36 mu = &sync.Mutex{}
37 k.m[key] = mu
38 }
39 k.mu.Unlock()
40
41 mu.Lock()
42 return mu.Unlock
43}
44
45func Version() (*version.Version, error) {
46 var buf bytes.Buffer
47 cmd := exec.Command("git", "version")
48 cmd.Stdout = &buf
49 cmd.Stderr = os.Stderr
50 err := cmd.Run()
51 if err != nil {
52 return nil, err
53 }
54 fields := strings.Fields(buf.String())
55 if len(fields) < 3 {
56 return nil, fmt.Errorf("invalid git version: %s", buf.String())
57 }
58
59 // version string is like: "git version 2.29.3" or "git version 2.29.3.windows.1"
60 versionString := fields[2]
61 if pos := strings.Index(versionString, "windows"); pos >= 1 {
62 versionString = versionString[:pos-1]
63 }
64 return version.NewVersion(versionString)
65}
66
67const WorkflowDir = `/.tangled/workflows`
68
69func runGit(ctx context.Context, args ...string) error {
70 var stderr bytes.Buffer
71 cmd := exec.CommandContext(ctx, "git", args...)
72 cmd.Stderr = &stderr
73 if err := cmd.Run(); err != nil {
74 return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
75 }
76 return nil
77}
78
79func SparseSyncGitRepo(ctx context.Context, cloneUri, path, rev string) error {
80 defer repoLocks.lock(path)()
81
82 exist, err := isDir(path)
83 if err != nil {
84 return err
85 }
86 if exist {
87 gitDirExist, err := isDir(path + "/.git")
88 if err != nil {
89 return err
90 }
91 if !gitDirExist {
92 if err := os.RemoveAll(path); err != nil {
93 return fmt.Errorf("cleanup invalid git dir: %w", err)
94 }
95 exist = false
96 }
97 }
98 if rev == "" {
99 rev = "HEAD"
100 }
101 if !exist {
102 if err := runGit(ctx, "clone", "--no-checkout", "--depth=1", "--filter=tree:0", "--revision="+rev, cloneUri, path); err != nil {
103 return fmt.Errorf("git clone: %w", err)
104 }
105 if err := runGit(ctx, "-C", path, "sparse-checkout", "set", "--no-cone", WorkflowDir); err != nil {
106 return fmt.Errorf("git sparse-checkout set: %w", err)
107 }
108 } else {
109 if err := runGit(ctx, "-C", path, "fetch", "--depth=1", "--filter=tree:0", "origin", rev); err != nil {
110 // remove any locks if the repo was left in a mid fetch state
111 removeStaleLocks(path)
112 if retryErr := runGit(ctx, "-C", path, "fetch", "--depth=1", "--filter=tree:0", "origin", rev); retryErr != nil {
113 // if still broken, wipe and refetch
114 if rmErr := os.RemoveAll(path); rmErr != nil {
115 return fmt.Errorf("git fetch: %w (cleanup failed: %v)", retryErr, rmErr)
116 }
117 if cloneErr := runGit(ctx, "clone", "--no-checkout", "--depth=1", "--filter=tree:0", "--revision="+rev, cloneUri, path); cloneErr != nil {
118 return fmt.Errorf("git fetch: %w (re-clone failed: %v)", retryErr, cloneErr)
119 }
120 if cloneErr := runGit(ctx, "-C", path, "sparse-checkout", "set", "--no-cone", WorkflowDir); cloneErr != nil {
121 return fmt.Errorf("git sparse-checkout set: %w", cloneErr)
122 }
123 }
124 }
125 }
126 if err := runGit(ctx, "-C", path, "checkout", rev); err != nil {
127 return fmt.Errorf("git checkout: %w", err)
128 }
129 return nil
130}
131
132func removeStaleLocks(path string) {
133 // removes shallow.lock, index.lock, etc., all are stale locks
134 // worst case scenario we fall through to wipe and refetch anyway
135 locks, _ := filepath.Glob(filepath.Join(path, ".git", "*.lock"))
136 for _, lock := range locks {
137 os.Remove(lock)
138 }
139}
140
141func isDir(path string) (bool, error) {
142 info, err := os.Stat(path)
143 if err == nil && info.IsDir() {
144 return true, nil
145 }
146 if os.IsNotExist(err) {
147 return false, nil
148 }
149 return false, err
150}