pull.go

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

Source Files