package project import ( "fmt" "os" "path/filepath" "sort" "strings" ) // Initializing is a statement, and the only one that matters here: *this* // directory is the project whose issues live in it. It is answered once, by a // person, and everything downstream reads the answer instead of guessing. // // The marker is deliberately something an operator makes, not something // inferred from the tree: `.git` is in every clone including this repository's // own, so a plugin that inferred its root from one wrote issues into itself. // Layouts this has been through, migrated in on init in the order listed — // oldest first, so a tree that skipped a generation still lands in one place. // // Each is a move, never a copy: two stores is the state the marker exists to // prevent, and a store left behind at an old path is a store somebody will edit // by accident months later. var legacy = map[string][]string{ "issues": { filepath.Join("tmp", "issues"), filepath.Join(".tea", "issues"), }, "payload": { filepath.Join("tmp", "payload"), filepath.Join(".tea", "payload"), }, } // ClashError reports that a migration found the same name on both sides. // // Two versions of one issue, and which one survives is not a decision a // migration gets to make quietly. type ClashError struct { Src, Dst string Names []string } func (e *ClashError) Error() string { names := e.Names suffix := "" if len(names) > 5 { suffix = fmt.Sprintf(" (+%d more)", len(names)-5) names = names[:5] } return fmt.Sprintf("%s and %s both hold %s%s — move or delete one side first; nothing was changed", e.Src, e.Dst, strings.Join(names, ", "), suffix) } // Init makes root a project. Everything it does is idempotent: // // - creates .kettle/issues/ and .kettle/payload/ // - moves an older store in, if it finds one — see `legacy`, oldest first // - adds .kettle/ to .gitignore // // The move is the migration off an older layout and it is a move, not a copy: // two stores is the state the marker exists to prevent, and a store left behind // at the old path is a store somebody will edit by accident. // // .kettle/ is gitignored because an `origin: local` issue is the only copy of that // work and the operator, not this command, decides what goes in a shared // history. Committing the store is a legitimate choice — drop the line if you // make it. // // Returns one line per thing done, for the receipt. func Init(root string, dryRun bool) ([]string, error) { var done []string marker := filepath.Join(root, Marker) fresh := !isDir(marker) for _, name := range []string{"issues", "payload"} { d := filepath.Join(marker, name) if isDir(d) { continue } if !dryRun { if err := os.MkdirAll(d, 0o755); err != nil { return done, err } } done = append(done, "created "+filepath.Join(Marker, name)) } for _, name := range []string{"issues", "payload"} { for _, old := range legacy[name] { src := filepath.Join(root, old) moved, err := migrate(src, filepath.Join(marker, name), dryRun) if err != nil { return done, err } switch { case moved == nil: // nothing there to migrate case len(moved) == 0: done = append(done, old+" was empty — nothing to move") default: done = append(done, fmt.Sprintf("moved %d file(s) from %s to %s", len(moved), old, filepath.Join(Marker, name))) } } } // The old marker goes only when the migration emptied it — anything else // parked in there is somebody's, and this is not the command that decides // what. if !dryRun { os.Remove(filepath.Join(root, ".tea")) } added, err := addToGitignore(filepath.Join(root, ".gitignore"), Marker+"/", dryRun) if err != nil { return done, err } if added { done = append(done, "added "+Marker+"/ to .gitignore") } switch { case len(done) == 0: done = append(done, "already initialized — nothing to do") case fresh: done = append(done, fmt.Sprintf("%s now tracks issues in %s/issues", root, Marker)) } return done, nil } // migrate moves the CONTENTS of src into dst — contents, not the directory, so // an already-created destination is not a reason to refuse. Returns the names // moved, or nil when there was nothing to migrate. func migrate(src, dst string, dryRun bool) ([]string, error) { if !isDir(src) { return nil, nil } entries, err := os.ReadDir(src) if err != nil { return nil, err } names := make([]string, 0, len(entries)) for _, e := range entries { names = append(names, e.Name()) } sort.Strings(names) if len(names) == 0 { return []string{}, nil } var clashes []string for _, n := range names { if _, err := os.Lstat(filepath.Join(dst, n)); err == nil { clashes = append(clashes, n) } } if len(clashes) > 0 { return nil, &ClashError{Src: src, Dst: dst, Names: clashes} } if dryRun { return names, nil } if err := os.MkdirAll(dst, 0o755); err != nil { return nil, err } for _, n := range names { if err := os.Rename(filepath.Join(src, n), filepath.Join(dst, n)); err != nil { return nil, err } } os.Remove(src) // only succeeds when we emptied it, which is the intent return names, nil } // addToGitignore appends entry unless some line already ignores it. func addToGitignore(path, entry string, dryRun bool) (bool, error) { var lines []string if raw, err := os.ReadFile(path); err == nil { lines = strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n") } else if !os.IsNotExist(err) { return false, err } want := strings.TrimSuffix(entry, "/") for _, line := range lines { if strings.TrimSuffix(strings.TrimSpace(line), "/") == want { return false, nil } } if dryRun { return true, nil } trailer := "\n" if len(lines) == 0 || lines[len(lines)-1] == "" { trailer = "" } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return false, err } defer f.Close() if _, err := f.WriteString(trailer + entry + "\n"); err != nil { return false, err } return true, nil }