From 3260251329bc05fe060ea97cb9f8af109f96b8a0 Mon Sep 17 00:00:00 2001 From: tester Date: Sat, 16 May 2026 02:23:08 -0700 Subject: [PATCH] feat(cmd): add rollback-plan command for change rollback analysis Adds `td rollback-plan` to enumerate commits between HEAD and a base branch and emit a structured rollback plan with revert commands, files touched, and notes on migrations/config/dependencies. Supports text, json, and markdown output and can write to file via --output. Nightshift-Task: rollback-plan Nightshift-Ref: https://github.com/marcus/nightshift --- cmd/rollback_plan.go | 352 ++++++++++++++++++++++++++++++++++++++ cmd/rollback_plan_test.go | 210 +++++++++++++++++++++++ 2 files changed, 562 insertions(+) create mode 100644 cmd/rollback_plan.go create mode 100644 cmd/rollback_plan_test.go diff --git a/cmd/rollback_plan.go b/cmd/rollback_plan.go new file mode 100644 index 00000000..71c79b9d --- /dev/null +++ b/cmd/rollback_plan.go @@ -0,0 +1,352 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +var ( + rollbackBase string + rollbackFormat string + rollbackOutput string + rollbackSince string +) + +// CommitInfo describes a single commit in the rollback plan. +type CommitInfo struct { + SHA string `json:"sha"` + Short string `json:"short"` + Author string `json:"author"` + Date string `json:"date"` + Subject string `json:"subject"` + IsMerge bool `json:"is_merge"` + Files []string `json:"files"` +} + +// RollbackPlan is the structured output produced by the command. +type RollbackPlan struct { + Base string `json:"base"` + Head string `json:"head"` + Since string `json:"since,omitempty"` + Commits []CommitInfo `json:"commits"` + FilesTouched []string `json:"files_touched"` + RevertCommands []string `json:"revert_commands"` + Notes []string `json:"notes"` +} + +var rollbackPlanCmd = &cobra.Command{ + Use: "rollback-plan", + Short: "Generate a rollback plan from git history", + Long: `Analyze git history between HEAD and a base branch to produce a structured +rollback plan including commits to revert, files touched, suggested git +commands, and migration/config considerations. + +Output formats: text (default), json, markdown.`, + GroupID: "system", + RunE: func(cmd *cobra.Command, args []string) error { + plan, err := buildRollbackPlan(rollbackBase, rollbackSince) + if err != nil { + return err + } + + out, err := renderRollbackPlan(plan, rollbackFormat) + if err != nil { + return err + } + + if rollbackOutput != "" { + if err := os.WriteFile(rollbackOutput, []byte(out), 0644); err != nil { + return fmt.Errorf("writing output: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Wrote rollback plan to %s\n", rollbackOutput) + return nil + } + + fmt.Fprint(cmd.OutOrStdout(), out) + return nil + }, +} + +func init() { + rollbackPlanCmd.Flags().StringVar(&rollbackBase, "base", "main", "base branch to compare against") + rollbackPlanCmd.Flags().StringVarP(&rollbackFormat, "format", "f", "text", "output format: text, json, markdown") + rollbackPlanCmd.Flags().StringVarP(&rollbackOutput, "output", "o", "", "write output to file instead of stdout") + rollbackPlanCmd.Flags().StringVar(&rollbackSince, "since", "", "commit/tag to use as the lower bound (overrides --base)") + rootCmd.AddCommand(rollbackPlanCmd) +} + +// gitRunner allows tests to inject a fake git executor. +var gitRunner = runGitForRollback + +func runGitForRollback(args ...string) (string, error) { + cmd := exec.Command("git", args...) + var stderr strings.Builder + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + } + return string(out), nil +} + +func buildRollbackPlan(base, since string) (*RollbackPlan, error) { + lower := base + if since != "" { + lower = since + } + + headOut, err := gitRunner("rev-parse", "HEAD") + if err != nil { + return nil, err + } + head := strings.TrimSpace(headOut) + + rangeSpec := fmt.Sprintf("%s..HEAD", lower) + // Format fields separated by NUL; record separator is \x1e. + const sep = "\x1f" + const recSep = "\x1e" + format := strings.Join([]string{"%H", "%h", "%an", "%aI", "%s", "%P"}, sep) + logOut, err := gitRunner("log", "--no-color", "--pretty=format:"+format+recSep, rangeSpec) + if err != nil { + return nil, err + } + + plan := &RollbackPlan{ + Base: base, + Head: head, + Since: since, + Commits: []CommitInfo{}, + FilesTouched: []string{}, + RevertCommands: []string{}, + Notes: []string{}, + } + + commits, err := parseCommitLog(logOut, sep, recSep) + if err != nil { + return nil, err + } + + fileSet := map[string]struct{}{} + for i := range commits { + c := &commits[i] + files, err := commitFiles(c.SHA) + if err != nil { + return nil, err + } + c.Files = files + for _, f := range files { + fileSet[f] = struct{}{} + } + if !c.IsMerge { + plan.RevertCommands = append(plan.RevertCommands, fmt.Sprintf("git revert --no-edit %s", c.Short)) + } else { + plan.RevertCommands = append(plan.RevertCommands, fmt.Sprintf("git revert -m 1 --no-edit %s", c.Short)) + } + } + plan.Commits = commits + + for f := range fileSet { + plan.FilesTouched = append(plan.FilesTouched, f) + } + sort.Strings(plan.FilesTouched) + + plan.Notes = deriveNotes(plan.FilesTouched, commits) + return plan, nil +} + +func parseCommitLog(raw, sep, recSep string) ([]CommitInfo, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return []CommitInfo{}, nil + } + records := strings.Split(raw, recSep) + commits := make([]CommitInfo, 0, len(records)) + for _, rec := range records { + rec = strings.TrimLeft(rec, "\n") + rec = strings.TrimSpace(rec) + if rec == "" { + continue + } + parts := strings.SplitN(rec, sep, 6) + if len(parts) < 6 { + return nil, fmt.Errorf("unexpected git log record: %q", rec) + } + parents := strings.Fields(parts[5]) + commits = append(commits, CommitInfo{ + SHA: parts[0], + Short: parts[1], + Author: parts[2], + Date: parts[3], + Subject: parts[4], + IsMerge: len(parents) > 1, + }) + } + return commits, nil +} + +func commitFiles(sha string) ([]string, error) { + out, err := gitRunner("show", "--name-only", "--pretty=format:", sha) + if err != nil { + return nil, err + } + var files []string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + files = append(files, line) + } + sort.Strings(files) + return files, nil +} + +func deriveNotes(files []string, commits []CommitInfo) []string { + var notes []string + for _, f := range files { + lower := strings.ToLower(f) + switch { + case strings.Contains(lower, "migration") || strings.Contains(lower, "schema.sql") || strings.HasSuffix(lower, ".sql"): + notes = append(notes, fmt.Sprintf("Database migration touched (%s): reverting code may not undo schema changes — plan a down-migration.", f)) + case strings.HasSuffix(lower, "go.mod") || strings.HasSuffix(lower, "go.sum") || strings.HasSuffix(lower, "package.json") || strings.HasSuffix(lower, "package-lock.json"): + notes = append(notes, fmt.Sprintf("Dependency manifest touched (%s): re-run dependency install after revert.", f)) + case strings.Contains(lower, "config") && (strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".json") || strings.HasSuffix(lower, ".toml")): + notes = append(notes, fmt.Sprintf("Config file touched (%s): verify deployed configuration is rolled back in lockstep.", f)) + } + } + for _, c := range commits { + if c.IsMerge { + notes = append(notes, fmt.Sprintf("Commit %s is a merge — revert with `git revert -m 1`.", c.Short)) + } + } + if len(commits) == 0 { + notes = append(notes, "No commits found between base and HEAD — nothing to roll back.") + } + return notes +} + +func renderRollbackPlan(plan *RollbackPlan, format string) (string, error) { + switch strings.ToLower(format) { + case "", "text": + return renderText(plan), nil + case "json": + b, err := json.MarshalIndent(plan, "", " ") + if err != nil { + return "", err + } + return string(b) + "\n", nil + case "markdown", "md": + return renderMarkdown(plan), nil + default: + return "", fmt.Errorf("unknown format %q (expected text, json, or markdown)", format) + } +} + +func renderText(plan *RollbackPlan) string { + var b strings.Builder + fmt.Fprintf(&b, "Rollback plan: %s..HEAD\n", planLowerBound(plan)) + fmt.Fprintf(&b, "HEAD: %s\n", plan.Head) + fmt.Fprintf(&b, "Commits: %d\n", len(plan.Commits)) + fmt.Fprintf(&b, "Files touched: %d\n\n", len(plan.FilesTouched)) + + if len(plan.Commits) > 0 { + b.WriteString("Commits (newest first):\n") + for _, c := range plan.Commits { + marker := "" + if c.IsMerge { + marker = " [merge]" + } + fmt.Fprintf(&b, " %s %s %s%s\n", c.Short, c.Date, c.Subject, marker) + } + b.WriteString("\n") + } + + if len(plan.RevertCommands) > 0 { + b.WriteString("Suggested revert commands (apply in order):\n") + for _, cmd := range plan.RevertCommands { + fmt.Fprintf(&b, " %s\n", cmd) + } + b.WriteString("\n") + } + + if len(plan.FilesTouched) > 0 { + b.WriteString("Files touched:\n") + for _, f := range plan.FilesTouched { + fmt.Fprintf(&b, " %s\n", f) + } + b.WriteString("\n") + } + + if len(plan.Notes) > 0 { + b.WriteString("Notes:\n") + for _, n := range plan.Notes { + fmt.Fprintf(&b, " - %s\n", n) + } + } + return b.String() +} + +func renderMarkdown(plan *RollbackPlan) string { + var b strings.Builder + fmt.Fprintf(&b, "# Rollback Plan\n\n") + fmt.Fprintf(&b, "- **Range:** `%s..HEAD`\n", planLowerBound(plan)) + fmt.Fprintf(&b, "- **HEAD:** `%s`\n", plan.Head) + fmt.Fprintf(&b, "- **Commits:** %d\n", len(plan.Commits)) + fmt.Fprintf(&b, "- **Files touched:** %d\n\n", len(plan.FilesTouched)) + + if len(plan.Commits) > 0 { + b.WriteString("## Commits\n\n") + b.WriteString("| SHA | Date | Author | Subject |\n") + b.WriteString("|-----|------|--------|---------|\n") + for _, c := range plan.Commits { + subject := c.Subject + if c.IsMerge { + subject = "[merge] " + subject + } + fmt.Fprintf(&b, "| `%s` | %s | %s | %s |\n", c.Short, c.Date, c.Author, escapeMD(subject)) + } + b.WriteString("\n") + } + + if len(plan.RevertCommands) > 0 { + b.WriteString("## Suggested Revert Commands\n\n```bash\n") + for _, cmd := range plan.RevertCommands { + b.WriteString(cmd + "\n") + } + b.WriteString("```\n\n") + } + + if len(plan.FilesTouched) > 0 { + b.WriteString("## Files Touched\n\n") + for _, f := range plan.FilesTouched { + fmt.Fprintf(&b, "- `%s`\n", f) + } + b.WriteString("\n") + } + + if len(plan.Notes) > 0 { + b.WriteString("## Notes\n\n") + for _, n := range plan.Notes { + fmt.Fprintf(&b, "- %s\n", n) + } + b.WriteString("\n") + } + return b.String() +} + +func planLowerBound(plan *RollbackPlan) string { + if plan.Since != "" { + return plan.Since + } + return plan.Base +} + +func escapeMD(s string) string { + return strings.ReplaceAll(s, "|", "\\|") +} diff --git a/cmd/rollback_plan_test.go b/cmd/rollback_plan_test.go new file mode 100644 index 00000000..e222a979 --- /dev/null +++ b/cmd/rollback_plan_test.go @@ -0,0 +1,210 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +func newFakeGit(t *testing.T) (*fakeGit, func()) { + t.Helper() + prev := gitRunner + f := &fakeGit{responses: map[string]gitResponse{}} + gitRunner = f.run + return f, func() { gitRunner = prev } +} + +type gitResponse struct { + out string + err error +} + +type fakeGit struct { + responses map[string]gitResponse + calls [][]string +} + +func (f *fakeGit) run(args ...string) (string, error) { + f.calls = append(f.calls, args) + key := args[0] + if r, ok := f.responses[key]; ok { + return r.out, r.err + } + return "", fmt.Errorf("no fake response for git %s", strings.Join(args, " ")) +} + +func TestBuildRollbackPlan_NoCommits(t *testing.T) { + f, restore := newFakeGit(t) + defer restore() + f.responses["rev-parse"] = gitResponse{out: "deadbeef\n"} + f.responses["log"] = gitResponse{out: ""} + + plan, err := buildRollbackPlan("main", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plan.Commits) != 0 { + t.Errorf("expected 0 commits, got %d", len(plan.Commits)) + } + if plan.Head != "deadbeef" { + t.Errorf("expected HEAD=deadbeef, got %s", plan.Head) + } + foundNote := false + for _, n := range plan.Notes { + if strings.Contains(n, "nothing to roll back") { + foundNote = true + } + } + if !foundNote { + t.Errorf("expected 'nothing to roll back' note, got %v", plan.Notes) + } +} + +func TestBuildRollbackPlan_EnumeratesCommits(t *testing.T) { + const sep = "\x1f" + const rec = "\x1e" + logOut := strings.Join([]string{"abc123def", "abc123d", "Alice", "2026-01-01T00:00:00Z", "feat: add thing", "parent1"}, sep) + rec + + strings.Join([]string{"fff222aaa", "fff222a", "Bob", "2026-01-02T00:00:00Z", "Merge pull request", "parent1 parent2"}, sep) + rec + + f, restore := newFakeGit(t) + defer restore() + f.responses["rev-parse"] = gitResponse{out: "abc123def\n"} + f.responses["log"] = gitResponse{out: logOut} + f.responses["show"] = gitResponse{out: "cmd/foo.go\ngo.mod\n"} + + plan, err := buildRollbackPlan("main", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plan.Commits) != 2 { + t.Fatalf("expected 2 commits, got %d", len(plan.Commits)) + } + if plan.Commits[0].IsMerge { + t.Errorf("first commit should not be merge") + } + if !plan.Commits[1].IsMerge { + t.Errorf("second commit should be merge") + } + // Revert command for non-merge: + if plan.RevertCommands[0] != "git revert --no-edit abc123d" { + t.Errorf("unexpected revert cmd: %s", plan.RevertCommands[0]) + } + // Revert command for merge: + if plan.RevertCommands[1] != "git revert -m 1 --no-edit fff222a" { + t.Errorf("unexpected merge revert cmd: %s", plan.RevertCommands[1]) + } + // Files deduplicated and sorted: + wantFiles := []string{"cmd/foo.go", "go.mod"} + if len(plan.FilesTouched) != len(wantFiles) { + t.Fatalf("expected %d files, got %d (%v)", len(wantFiles), len(plan.FilesTouched), plan.FilesTouched) + } + for i, f := range wantFiles { + if plan.FilesTouched[i] != f { + t.Errorf("files[%d]=%s want %s", i, plan.FilesTouched[i], f) + } + } + // go.mod triggers a dependency note. + foundDep := false + foundMerge := false + for _, n := range plan.Notes { + if strings.Contains(n, "Dependency manifest") { + foundDep = true + } + if strings.Contains(n, "is a merge") { + foundMerge = true + } + } + if !foundDep { + t.Errorf("expected dependency note in %v", plan.Notes) + } + if !foundMerge { + t.Errorf("expected merge note in %v", plan.Notes) + } +} + +func TestRenderRollbackPlan_Formats(t *testing.T) { + plan := &RollbackPlan{ + Base: "main", + Head: "abc123def", + Commits: []CommitInfo{{ + SHA: "abc123def", + Short: "abc123d", + Author: "Alice", + Date: "2026-01-01T00:00:00Z", + Subject: "feat: add thing | with pipe", + Files: []string{"cmd/foo.go"}, + }}, + FilesTouched: []string{"cmd/foo.go"}, + RevertCommands: []string{"git revert --no-edit abc123d"}, + Notes: []string{"check things"}, + } + + text, err := renderRollbackPlan(plan, "text") + if err != nil { + t.Fatalf("text render: %v", err) + } + if !strings.Contains(text, "abc123d") || !strings.Contains(text, "git revert --no-edit abc123d") { + t.Errorf("text output missing data: %s", text) + } + + md, err := renderRollbackPlan(plan, "markdown") + if err != nil { + t.Fatalf("markdown render: %v", err) + } + if !strings.Contains(md, "# Rollback Plan") || !strings.Contains(md, "| `abc123d` |") { + t.Errorf("markdown output unexpected: %s", md) + } + if !strings.Contains(md, "with pipe") || !strings.Contains(md, "\\|") { + t.Errorf("markdown pipe escaping missing: %s", md) + } + + jsonOut, err := renderRollbackPlan(plan, "json") + if err != nil { + t.Fatalf("json render: %v", err) + } + var round RollbackPlan + if err := json.Unmarshal([]byte(jsonOut), &round); err != nil { + t.Fatalf("json invalid: %v\n%s", err, jsonOut) + } + if round.Head != "abc123def" || len(round.Commits) != 1 { + t.Errorf("json roundtrip mismatch: %+v", round) + } + + if _, err := renderRollbackPlan(plan, "bogus"); err == nil { + t.Errorf("expected error for unknown format") + } +} + +func TestParseCommitLog_TrailingWhitespace(t *testing.T) { + const sep = "\x1f" + const rec = "\x1e" + raw := strings.Join([]string{"abc", "a", "Alice", "2026-01-01", "msg", "p1"}, sep) + rec + "\n \n" + commits, err := parseCommitLog(raw, sep, rec) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(commits) != 1 { + t.Errorf("expected 1 commit, got %d", len(commits)) + } +} + +func TestDeriveNotes_ConfigAndMigration(t *testing.T) { + files := []string{"db/migrations/0001_init.sql", "deploy/config.yaml", "src/index.js"} + notes := deriveNotes(files, nil) + var hasMigration, hasConfig bool + for _, n := range notes { + if strings.Contains(n, "Database migration") { + hasMigration = true + } + if strings.Contains(n, "Config file") { + hasConfig = true + } + } + if !hasMigration { + t.Errorf("expected migration note: %v", notes) + } + if !hasConfig { + t.Errorf("expected config note: %v", notes) + } +}