package cli import ( "flag" "fmt" "os" "path/filepath" "strings" "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) } return changed, left, OK } // 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 }