| 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 | |