- документ самоуправления объявляется ключом governance, а не угадывается по «он один и без ключей оси»: конвенция, потерявшая topic, была от него неотличима и тихо теряла все проверки об отъезде к потребителю - проверка путей канона больше не ловит README.md и READING.md — эти два имени значат что-то и на стороне потребителя - lang.Recognize требует совпадения и слов, и номера версии; директории компонентов сверяются на вложенность, а не только на равенство - у обеих проверок появился --json, а convy sync называет ссылки на темы, которых компонент не взял
239 lines
8.0 KiB
Go
239 lines
8.0 KiB
Go
package cli
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"git.vakhrushev.me/av/convy/internal/check"
|
|
"git.vakhrushev.me/av/convy/internal/manifest"
|
|
"git.vakhrushev.me/av/convy/internal/project"
|
|
)
|
|
|
|
// convy sync makes the files agree with the manifest. The manifest is the
|
|
// truth: it says which components exist, where they write and what each takes,
|
|
// and everything under those directories follows from that.
|
|
//
|
|
// It divides from pull by what it is about. pull is about the contents of a
|
|
// copy — it takes the text of every subscription afresh, and the diff it leaves
|
|
// is the point of running it. sync is about the set of files: what the manifest
|
|
// calls for and is not there gets assembled, what is there and nothing calls
|
|
// for gets reported and, when nothing of the repository is in it, removed.
|
|
//
|
|
// A copy carrying a local part is never removed. Below the marker is the one
|
|
// thing in the directory that exists nowhere else, and a command that tidies up
|
|
// has no business deciding it is spent.
|
|
|
|
func runSync(env Env, args []string) ExitCode {
|
|
fs := flag.NewFlagSet("convy sync", flag.ContinueOnError)
|
|
fs.SetOutput(env.Err)
|
|
root := fs.String("root", "", "root of the project; it is looked up upwards by default")
|
|
forComponent := fs.String("for", "", "component to bring in line; every one of them by default")
|
|
dry := fs.Bool("dry-run", false, "say what would change and change nothing")
|
|
if err := fs.Parse(args); err != nil {
|
|
return Usage
|
|
}
|
|
if code := noStrayArgs(env, "convy sync", fs.Args()); code != OK {
|
|
return code
|
|
}
|
|
|
|
o, code := openProject(env, *root)
|
|
if code != OK {
|
|
return code
|
|
}
|
|
defer o.Close()
|
|
|
|
names, code := components(env, o.Manifest, *forComponent)
|
|
if code != OK {
|
|
return code
|
|
}
|
|
|
|
problems := validateManifest(o, names)
|
|
for _, p := range problems {
|
|
fmt.Fprintf(env.Err, "%s\n", p)
|
|
}
|
|
if len(problems) > 0 {
|
|
fmt.Fprintln(env.Err, "\nthe manifest is what the layout follows, so nothing was touched")
|
|
return Failed
|
|
}
|
|
|
|
changed, left := 0, 0
|
|
for i, name := range names {
|
|
c := o.Manifest.Components[name]
|
|
if i > 0 {
|
|
fmt.Fprintln(env.Out)
|
|
}
|
|
fmt.Fprintf(env.Out, "%s → %s\n", name, c.Dir)
|
|
n, stuck, code := syncComponent(env, o, c, *dry)
|
|
if code != OK {
|
|
return code
|
|
}
|
|
changed += n
|
|
left += stuck
|
|
}
|
|
|
|
fmt.Fprintln(env.Out)
|
|
switch {
|
|
case *dry && changed > 0:
|
|
fmt.Fprintf(env.Out, "%s would change; run without --dry-run to do it\n", plural(changed, "file"))
|
|
case changed > 0:
|
|
fmt.Fprintf(env.Out, "%s changed; convy pull takes the text of the rest afresh\n", plural(changed, "file"))
|
|
case left == 0:
|
|
fmt.Fprintln(env.Out, "the layout already follows the manifest")
|
|
}
|
|
if left > 0 {
|
|
fmt.Fprintf(env.Out, "%s left alone: nothing subscribes to it and it holds a local part\n", plural(left, "file"))
|
|
return Failed
|
|
}
|
|
return OK
|
|
}
|
|
|
|
// validate checks the manifest against itself and against the suite. Everything
|
|
// wrong is reported at once: being sent back one line at a time is the worst way
|
|
// to learn what a file wants.
|
|
func validateManifest(o *opened, names []string) []string {
|
|
var out []string
|
|
if err := distinctDirs(o.Manifest); err != nil {
|
|
out = append(out, err.Error())
|
|
}
|
|
for _, name := range names {
|
|
c := o.Manifest.Components[name]
|
|
if c.Dir == "" {
|
|
out = append(out, fmt.Sprintf("the component %q names no dir, and a copy has to be written somewhere", name))
|
|
}
|
|
if len(c.Lang) > 1 {
|
|
out = append(out, fmt.Sprintf("the component %q declares two languages (%s): a line of code is written in one of them, and a component is the region where every chosen layer holds at once — split it",
|
|
name, strings.Join(c.Lang, ", ")))
|
|
}
|
|
seen := make(map[string]bool, len(c.Topics))
|
|
for _, topic := range c.Topics {
|
|
switch {
|
|
case seen[topic]:
|
|
out = append(out, fmt.Sprintf("the component %q takes %q twice", name, topic))
|
|
case o.Suite.Manifest.TopicRetired(topic):
|
|
out = append(out, fmt.Sprintf("the component %q takes %q, which the suite has retired: %s",
|
|
name, topic, o.Suite.Manifest.Topics.Retired[topic]))
|
|
case !o.Suite.Manifest.TopicLive(topic):
|
|
out = append(out, fmt.Sprintf("the component %q takes %q, and the suite declares no such topic", name, topic))
|
|
default:
|
|
if taken, _ := o.Suite.Assemble(topic, project.Axis(c)); len(taken) == 0 {
|
|
out = append(out, fmt.Sprintf("the component %q takes %q, and no layer of it fits this component", name, topic))
|
|
}
|
|
}
|
|
seen[topic] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// syncComponent brings one directory in line. It returns how many files moved
|
|
// and how many it would not touch.
|
|
func syncComponent(env Env, o *opened, c manifest.Component, dry bool) (changed, left int, code ExitCode) {
|
|
|
|
for _, topic := range c.Topics {
|
|
rel := filepath.ToSlash(filepath.Join(c.Dir, topic+".md"))
|
|
if exists(filepath.Join(o.Root, filepath.FromSlash(rel))) {
|
|
continue
|
|
}
|
|
changed++
|
|
if dry {
|
|
fmt.Fprintf(env.Out, " + %-28s subscribed, and no file\n", rel)
|
|
continue
|
|
}
|
|
made, err := project.Assemble(o.Suite, o.Root, c, topic)
|
|
if err != nil {
|
|
fmt.Fprintf(env.Err, " %s: %s\n", topic, err)
|
|
return changed, left, Failed
|
|
}
|
|
fmt.Fprintf(env.Out, " + %-28s %s\n", made.Path, plural(len(made.Layers), "layer"))
|
|
}
|
|
|
|
orphans, err := orphaned(o.Root, c)
|
|
if err != nil {
|
|
fmt.Fprintln(env.Err, err)
|
|
return changed, left, Failed
|
|
}
|
|
for _, orphan := range orphans {
|
|
switch {
|
|
case orphan.local:
|
|
left++
|
|
fmt.Fprintf(env.Out, " ! %-28s nothing subscribes to %q, and it carries a local part: remove it by hand or subscribe again\n",
|
|
orphan.path, orphan.topic)
|
|
case dry:
|
|
changed++
|
|
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
|
|
default:
|
|
if err := os.Remove(filepath.Join(o.Root, filepath.FromSlash(orphan.path))); err != nil {
|
|
fmt.Fprintln(env.Err, err)
|
|
return changed, left, Failed
|
|
}
|
|
changed++
|
|
fmt.Fprintf(env.Out, " - %-28s nothing subscribes to %q\n", orphan.path, orphan.topic)
|
|
}
|
|
}
|
|
|
|
if !dry {
|
|
guide, err := project.Reading(o.LangRoot, o.Suite.Manifest.Language.Reading, o.Root, c.Dir)
|
|
if err != nil {
|
|
fmt.Fprintf(env.Err, " %s\n", err)
|
|
return changed, left, Failed
|
|
}
|
|
fmt.Fprintf(env.Out, " = %-28s the guide to reading a rule\n", guide)
|
|
}
|
|
reportDangling(env, o, c)
|
|
return changed, left, OK
|
|
}
|
|
|
|
// reportDangling says which copies point at topics this component did not take.
|
|
// It is a note rather than a finding: naming a rule of another topic outside
|
|
// the norm is allowed, and the reader loses a pointer rather than a
|
|
// requirement. But it is also the one thing about a copy that cannot be seen
|
|
// without the suite, so it is said where the suite is at hand.
|
|
func reportDangling(env Env, o *opened, c manifest.Component) {
|
|
docs, _ := copies(o.Root, c.Dir)
|
|
for _, d := range docs {
|
|
refs := check.Dangling(d, o.Suite, c.Subscribed)
|
|
if len(refs) == 0 {
|
|
continue
|
|
}
|
|
var parts []string
|
|
for _, ref := range refs {
|
|
parts = append(parts, fmt.Sprintf("%s (%s)", ref.Text, check.TopicOf(o.Suite, ref.Prefix)))
|
|
}
|
|
fmt.Fprintf(env.Out, " ? %-28s points at %s — not taken by this component\n",
|
|
d.Path, strings.Join(parts, ", "))
|
|
}
|
|
}
|
|
|
|
// orphan is a copy in a component directory that the manifest does not call for.
|
|
type orphan struct {
|
|
path string
|
|
topic string
|
|
local bool
|
|
}
|
|
|
|
// orphaned finds the copies nothing subscribes to. What is a copy is decided by
|
|
// the origin key: README.md belongs to the repository, READING.md belongs to the
|
|
// suite, and a file whose origin was taken away has become a document of the
|
|
// repository — none of the three is anyone's to remove.
|
|
func orphaned(root string, c manifest.Component) ([]orphan, error) {
|
|
docs, broken := copies(root, c.Dir)
|
|
if len(broken) > 0 {
|
|
return nil, broken[0]
|
|
}
|
|
var out []orphan
|
|
for _, d := range docs {
|
|
if c.Subscribed(d.Front.Origin) {
|
|
continue
|
|
}
|
|
local := ""
|
|
if at := d.Marker(); at > 0 {
|
|
local = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(d.Below(at)), project.LocalMarker))
|
|
}
|
|
out = append(out, orphan{path: d.Path, topic: d.Front.Origin, local: local != ""})
|
|
}
|
|
return out, nil
|
|
}
|