pull.go

v0.6.0
Doc Versions Source
1
package sync
2
3
import (
4
	"os"
5
	"path"
6
	"path/filepath"
7
	"sort"
8
	"strings"
9
10
	"go.bigb.es/auxilia/culpa"
11
	"go.bigb.es/auxilia/legatus"
12
)
13
14
// PullMode selects how Pull resolves a conflict (local exists and differs).
15
type PullMode int
16
17
const (
18
	// PullConfirm asks a yes/no Confirm to overwrite the whole file (default).
19
	PullConfirm PullMode = iota
20
	// PullInteractive offers per-file apply|skip|patch|quit and, in patch mode, a
21
	// per-hunk picker.
22
	PullInteractive
23
)
24
25
// Pull downloads remote files into the local tree. A new file (no local
26
// counterpart) is written directly. A conflict (local exists and differs) is
27
// resolved through the prompter (PC2): the non-interactive default declines, so
28
// the conflict is skipped and the local file is left untouched — never silently
29
// overwritten.
30
type Pull struct {
31
	// Files are individual paths relative to LocalRoot/RemoteRoot. Exclude does
32
	// not apply to these: naming a file explicitly is the intent.
33
	Files []string
34
	// Dirs are directory paths (relative) pulled recursively.
35
	Dirs []string
36
	// LocalRoot is the base of the local tree.
37
	LocalRoot string
38
	// RemoteRoot is the base of the remote tree.
39
	RemoteRoot string
40
	// Mode selects conflict resolution; the zero value is PullConfirm.
41
	Mode PullMode
42
	// Exclude holds gitignore-style patterns (see matchIgnore) scoping the Dirs
43
	// walk. Remote runtime state inside a synced dir — a database, a log — has no
44
	// local counterpart, so without this it reads as a *new* file and is written
45
	// straight into the local tree with no prompt.
46
	Exclude []string
47
}
48
49
// Name identifies the step.
50
func (p *Pull) Name() string { return "sync.pull" }
51
52
// Check reports whether any remote file is new or differs locally.
53
func (p *Pull) Check(fc *legatus.FlowCtx) (bool, error) {
54
	rels, err := p.relPaths(fc)
55
	if err != nil {
56
		return false, err
57
	}
58
	for _, rel := range rels {
59
		differs, _, _, err := p.compare(fc, rel)
60
		if err != nil {
61
			return false, err
62
		}
63
		if differs {
64
			return true, nil
65
		}
66
	}
67
	return false, nil
68
}
69
70
// Apply downloads each remote file and applies it. New files are written;
71
// conflicts go through the prompter. Outcome.Data is the sorted list of applied
72
// relative paths.
73
func (p *Pull) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) {
74
	rels, err := p.relPaths(fc)
75
	if err != nil {
76
		return legatus.Outcome{}, err
77
	}
78
79
	var applied []string
80
	for _, rel := range rels {
81
		differs, localMissing, remoteData, err := p.compare(fc, rel)
82
		if err != nil {
83
			return legatus.Outcome{}, err
84
		}
85
		if !differs {
86
			continue
87
		}
88
89
		// New file: no conflict, write directly in either mode.
90
		if localMissing {
91
			if err := p.writeLocal(rel, remoteData); err != nil {
92
				return legatus.Outcome{}, err
93
			}
94
			fc.Log().Info("pulled", "file", rel)
95
			applied = append(applied, rel)
96
			continue
97
		}
98
99
		// Conflict: local exists and differs.
100
		if p.Mode == PullInteractive {
101
			stop, did, err := p.resolveInteractive(fc, rel, remoteData)
102
			if err != nil {
103
				return legatus.Outcome{}, err
104
			}
105
			if did {
106
				applied = append(applied, rel)
107
			}
108
			if stop { // quit: leave this and all remaining files untouched
109
				break
110
			}
111
			continue
112
		}
113
114
		// PullConfirm: whole-file yes/no gate.
115
		ok, perr := fc.Prompter().Confirm(fc.Ctx(),
116
			"sync: overwrite local "+rel+" with remote version?")
117
		if perr != nil {
118
			return legatus.Outcome{}, culpa.Wrapf(perr, "sync: confirm overwrite %s", rel)
119
		}
120
		if !ok {
121
			fc.Log().Info("kept local file (overwrite declined)", "file", rel)
122
			continue
123
		}
124
		if err := p.writeLocal(rel, remoteData); err != nil {
125
			return legatus.Outcome{}, err
126
		}
127
		fc.Log().Info("pulled", "file", rel)
128
		applied = append(applied, rel)
129
	}
130
131
	sort.Strings(applied)
132
	return legatus.Outcome{
133
		Changed: len(applied) > 0,
134
		Message: uploadSummary(applied),
135
		Data:    applied,
136
	}, nil
137
}
138
139
// resolveInteractive resolves one conflict via apply|skip|patch|quit. stop=true
140
// on quit (the caller leaves this and all remaining files untouched); did=true
141
// when the local file was changed.
142
func (p *Pull) resolveInteractive(fc *legatus.FlowCtx, rel string, remoteData []byte) (stop, did bool, err error) {
143
	choice, perr := fc.Prompter().Select(fc.Ctx(),
144
		"sync: "+rel+" differs — apply, skip, patch, or quit?",
145
		[]string{"apply", "skip", "patch", "quit"})
146
	if perr != nil {
147
		return false, false, culpa.Wrapf(perr, "sync: select action %s", rel)
148
	}
149
	switch choice {
150
	case 0: // apply whole file
151
		if werr := p.writeLocal(rel, remoteData); werr != nil {
152
			return false, false, werr
153
		}
154
		fc.Log().Info("pulled", "file", rel)
155
		return false, true, nil
156
	case 1: // skip
157
		fc.Log().Info("kept local file (skipped)", "file", rel)
158
		return false, false, nil
159
	case 2: // patch
160
		return p.patchFile(fc, rel, remoteData)
161
	default: // quit
162
		return true, false, nil
163
	}
164
}
165
166
// patchFile runs the per-hunk picker and writes the merged result. A hunk-level
167
// quit leaves the file untouched and stops the whole step.
168
func (p *Pull) patchFile(fc *legatus.FlowCtx, rel string, remoteData []byte) (stop, did bool, err error) {
169
	localPath := filepath.Join(p.LocalRoot, filepath.FromSlash(rel))
170
	localData, lerr := os.ReadFile(localPath)
171
	if lerr != nil {
172
		return false, false, culpa.Wrapf(lerr, "sync: read local %s", localPath)
173
	}
174
	localLines := splitKeepLines(string(localData))
175
	remoteLines := splitKeepLines(string(remoteData))
176
	ops, hunks := splitHunks(localLines, remoteLines)
177
178
	selected := make([]bool, len(hunks))
179
	for i := range hunks {
180
		choice, perr := fc.Prompter().Select(fc.Ctx(),
181
			"sync: "+rel+" hunk:\n"+hunks[i].preview+"apply this hunk?",
182
			[]string{"apply hunk", "skip hunk", "quit"})
183
		if perr != nil {
184
			return false, false, culpa.Wrapf(perr, "sync: select hunk %s", rel)
185
		}
186
		switch choice {
187
		case 0:
188
			selected[i] = true
189
		case 1:
190
			selected[i] = false
191
		default: // quit: leave the file untouched, stop everything
192
			return true, false, nil
193
		}
194
	}
195
196
	merged := strings.Join(applyHunks(localLines, remoteLines, ops, hunks, selected), "")
197
	if merged == string(localData) {
198
		return false, false, nil // nothing selected ⇒ no change
199
	}
200
	if werr := p.writeLocal(rel, []byte(merged)); werr != nil {
201
		return false, false, werr
202
	}
203
	fc.Log().Info("patched", "file", rel)
204
	return false, true, nil
205
}
206
207
// compare downloads the remote file and reports whether it differs from local.
208
// localMissing reports whether the local counterpart is absent (new-file case).
209
func (p *Pull) compare(fc *legatus.FlowCtx, rel string) (differs, localMissing bool, remoteData []byte, err error) {
210
	remotePath := path.Join(p.RemoteRoot, rel)
211
	remoteData, err = downloadRemote(fc.Ctx(), fc.Conn(), remotePath)
212
	if err != nil {
213
		return false, false, nil, err
214
	}
215
216
	localPath := filepath.Join(p.LocalRoot, filepath.FromSlash(rel))
217
	localData, lerr := os.ReadFile(localPath)
218
	if os.IsNotExist(lerr) {
219
		return true, true, remoteData, nil
220
	}
221
	if lerr != nil {
222
		return false, false, nil, culpa.Wrapf(lerr, "sync: read local %s", localPath)
223
	}
224
	return string(localData) != string(remoteData), false, remoteData, nil
225
}
226
227
func (p *Pull) writeLocal(rel string, data []byte) error {
228
	localPath := filepath.Join(p.LocalRoot, filepath.FromSlash(rel))
229
	if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
230
		return culpa.Wrapf(err, "sync: mkdir for %s", localPath)
231
	}
232
	if err := os.WriteFile(localPath, data, 0o644); err != nil {
233
		return culpa.Wrapf(err, "sync: write local %s", localPath)
234
	}
235
	return nil
236
}
237
238
func (p *Pull) relPaths(fc *legatus.FlowCtx) ([]string, error) {
239
	set := map[string]bool{}
240
	for _, f := range p.Files {
241
		set[filepath.ToSlash(f)] = true
242
	}
243
	ignore := newIgnoreSet(p.Exclude)
244
	for _, dir := range p.Dirs {
245
		remoteRels, err := listRemoteFiles(fc.Ctx(), fc.Conn(),
246
			path.Join(p.RemoteRoot, filepath.ToSlash(dir)))
247
		if err != nil {
248
			return nil, err
249
		}
250
		dirSlash := filepath.ToSlash(dir)
251
		for _, r := range remoteRels {
252
			rel := joinRel(dirSlash, r)
253
			if ignore.match(rel) {
254
				continue
255
			}
256
			set[rel] = true
257
		}
258
	}
259
	rels := make([]string, 0, len(set))
260
	for rel := range set {
261
		rels = append(rels, rel)
262
	}
263
	sort.Strings(rels)
264
	return rels, nil
265
}
266

Source Files