-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsync.go
More file actions
274 lines (234 loc) · 9.78 KB
/
Copy pathsync.go
File metadata and controls
274 lines (234 loc) · 9.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/url"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
)
// dirLocks holds one lock per working directory. Keys are derived from
// configured repository names, so the map is bounded and never evicted.
var (
dirLocks = make(map[string]sync.Locker)
dirLocksMu sync.Locker = &sync.Mutex{}
)
func getDirLock(dir string) sync.Locker {
dirLocksMu.Lock()
defer dirLocksMu.Unlock()
if lock, ok := dirLocks[dir]; ok {
return lock
}
lock := &sync.Mutex{}
dirLocks[dir] = lock
return lock
}
// SyncRepoKeyValuesParams contains all parameters needed to synchronize key-value data in a Git repository.
// An empty Data field can be used to initialize the repository and checkout the branch without making changes.
type SyncRepoKeyValuesParams struct {
// Dir is the local directory path where the Git repository will be cloned.
// This serves as the workspace for all Git operations.
Dir string
// URL is the Git repository remote URL.
URL string
// Username is the Git authentication username.
Username string
// Password is the Git authentication password or token.
Password string
// Branch is the Git branch to checkout and push to.
Branch string
// Path is the relative path within the repository to the JSON file storing key-value data.
// This file will be created or updated within the cloned repository.
Path string
// GitUserName is the author name for Git commits.
GitUserName string
// GitUserEmail is the author email for Git commits.
GitUserEmail string
// Data contains the key-value pairs to write to the JSON file.
// If empty, the function will only initialize the repository and checkout the branch without modifying any files.
Data map[string]string
// MaxRetries is the maximum number of retry attempts for failed operations.
MaxRetries int
}
// SyncRepoKeyValues synchronizes key-value data in a Git repository.
// It clones or pulls the repository, checks out the specified branch, and updates the JSON file with the provided data.
// If Data is empty, it only initializes the repository and checks out the branch without making any file changes.
//
// It acquires the per-directory lock before syncing; callers that already hold
// the lock (or want to bound only the sync work with a context deadline)
// should call syncRepoKeyValuesLocked directly.
func SyncRepoKeyValues(ctx context.Context, params SyncRepoKeyValuesParams) error {
// Serialize operations on the same working directory.
lock := getDirLock(params.Dir)
lock.Lock()
defer lock.Unlock()
return syncRepoKeyValuesLocked(ctx, params)
}
// syncRepoKeyValuesLocked is SyncRepoKeyValues without the per-directory
// locking. The caller must hold the lock for params.Dir (see getDirLock).
func syncRepoKeyValuesLocked(ctx context.Context, params SyncRepoKeyValuesParams) error {
logger := slog.With("dir", params.Dir, "branch", params.Branch, "path", params.Path)
logger.Info("start syncing repo key-values")
if params.MaxRetries <= 0 {
params.MaxRetries = 3
}
var lastErr error
for i := 0; i < params.MaxRetries; i++ {
if i > 0 {
delay := time.Second * time.Duration(i)
logger.Info("retrying after backoff", "attempt", i+1, "maxRetries", params.MaxRetries, "delay", delay)
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
if err := syncRepoKeyValuesOnce(ctx, params); err != nil {
lastErr = err
logger.Error("attempt failed", "attempt", i+1, "error", err)
continue
}
logger.Info("syncing repo key-values succeeded")
return nil
}
logger.Error("syncing repo key-values failed after retries", "maxRetries", params.MaxRetries, "error", lastErr)
return fmt.Errorf("failed after %d retries: %w", params.MaxRetries, lastErr)
}
// gitAuthArgs returns git arguments that pass credentials via an HTTP header,
// so secrets are never embedded in URLs, written to .git/config, or leaked
// into command output. It returns nil unless the remote URL uses HTTP(S)
// and both username and password are set.
func gitAuthArgs(params SyncRepoKeyValuesParams) []string {
if params.Username == "" || params.Password == "" {
return nil
}
u, err := url.Parse(params.URL)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
return nil
}
cred := base64.StdEncoding.EncodeToString([]byte(params.Username + ":" + params.Password))
return []string{"-c", "http.extraHeader=Authorization: Basic " + cred}
}
// runGit executes a git command with the given auth arguments. If dir is not
// empty, the command runs inside it (-C). It returns the combined output.
func runGit(ctx context.Context, auth []string, dir string, args ...string) (string, error) {
full := make([]string, 0, len(auth)+len(args)+2)
full = append(full, auth...)
if dir != "" {
full = append(full, "-C", dir)
}
full = append(full, args...)
cmd := exec.CommandContext(ctx, "git", full...)
out, err := cmd.CombinedOutput()
return string(out), err
}
func syncRepoKeyValuesOnce(ctx context.Context, params SyncRepoKeyValuesParams) error {
logger := slog.With("dir", params.Dir, "branch", params.Branch, "path", params.Path)
auth := gitAuthArgs(params)
gitDir := filepath.Join(params.Dir, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
// Not a git repository yet: wipe any leftover directory and clone from scratch.
if err := os.RemoveAll(params.Dir); err != nil {
return fmt.Errorf("failed to remove old dir: %w", err)
}
if err := os.MkdirAll(params.Dir, 0755); err != nil {
return fmt.Errorf("failed to create dir: %w", err)
}
logger.Info("cloning repository")
if out, err := runGit(ctx, auth, "", "clone", "-b", params.Branch, "--single-branch", params.URL, params.Dir); err != nil {
return fmt.Errorf("git clone failed: %w, output: %s", err, out)
}
} else if err != nil {
return fmt.Errorf("failed to check git dir: %w", err)
} else {
// Existing repository: point origin at the configured URL, then reset to the remote branch.
if _, err := runGit(ctx, auth, params.Dir, "remote", "get-url", "origin"); err != nil {
if out, err := runGit(ctx, auth, params.Dir, "remote", "add", "origin", params.URL); err != nil {
return fmt.Errorf("git remote add failed: %w, output: %s", err, out)
}
} else {
if out, err := runGit(ctx, auth, params.Dir, "remote", "set-url", "origin", params.URL); err != nil {
return fmt.Errorf("git remote set-url failed: %w, output: %s", err, out)
}
}
// Best-effort cleanup of local changes before resetting to the remote branch.
if out, err := runGit(ctx, auth, params.Dir, "clean", "-fd"); err != nil {
logger.Warn("git clean failed, continuing", "error", err, "output", out)
}
if out, err := runGit(ctx, auth, params.Dir, "reset", "--hard"); err != nil {
logger.Warn("git reset --hard failed, continuing", "error", err, "output", out)
}
if out, err := runGit(ctx, auth, params.Dir, "fetch", "origin", params.Branch); err != nil {
return fmt.Errorf("git fetch failed: %w, output: %s", err, out)
}
if out, err := runGit(ctx, auth, params.Dir, "reset", "--hard", "origin/"+params.Branch); err != nil {
return fmt.Errorf("git reset failed: %w, output: %s", err, out)
}
}
// Ensure a local branch tracks the remote (a fresh clone may leave a detached HEAD).
if out, err := runGit(ctx, auth, params.Dir, "checkout", "-B", params.Branch, "origin/"+params.Branch); err != nil {
return fmt.Errorf("git checkout failed: %w, output: %s", err, out)
}
if len(params.Data) == 0 {
logger.Info("no data to update, skipping file operations")
return nil
}
// Merge the payload into the JSON file.
filePath := filepath.Join(params.Dir, params.Path)
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return fmt.Errorf("failed to create file dir: %w", err)
}
var existingData map[string]any
if content, err := os.ReadFile(filePath); err == nil {
if err := json.Unmarshal(content, &existingData); err != nil {
logger.Warn("existing json file is invalid, overwriting", "filePath", filePath, "error", err)
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to read json file: %w", err)
}
if existingData == nil {
existingData = make(map[string]any)
}
for k, v := range params.Data {
existingData[k] = v
}
content, err := json.MarshalIndent(existingData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal json: %w", err)
}
if err := os.WriteFile(filePath, content, 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
// Stage the file first: "git diff --quiet" alone ignores untracked files,
// so a newly created JSON file would never be committed.
if out, err := runGit(ctx, auth, params.Dir, "add", params.Path); err != nil {
return fmt.Errorf("git add failed: %w, output: %s", err, out)
}
if _, err := runGit(ctx, auth, params.Dir, "diff", "--cached", "--quiet"); err == nil {
logger.Info("no changes detected, skipping commit")
return nil
}
// Configure the commit author; failures are non-fatal since git may fall
// back to a globally configured identity.
if out, err := runGit(ctx, auth, params.Dir, "config", "user.email", params.GitUserEmail); err != nil {
logger.Warn("failed to set git user.email", "error", err, "output", out)
}
if out, err := runGit(ctx, auth, params.Dir, "config", "user.name", params.GitUserName); err != nil {
logger.Warn("failed to set git user.name", "error", err, "output", out)
}
if out, err := runGit(ctx, auth, params.Dir, "commit", "-m", "update key-value"); err != nil {
return fmt.Errorf("git commit failed: %w, output: %s", err, out)
}
if out, err := runGit(ctx, auth, params.Dir, "push", "origin", params.Branch); err != nil {
return fmt.Errorf("git push failed: %w, output: %s", err, out)
}
logger.Info("syncing repo key-values once completed successfully")
return nil
}