package main import ( "fmt" "github.com/git-bug/git-bug/entities/bug" "github.com/git-bug/git-bug/entities/common" "github.com/git-bug/git-bug/entities/identity" "github.com/git-bug/git-bug/entity" "github.com/git-bug/git-bug/entity/dag" "github.com/git-bug/git-bug/repository" ) // supersededByMetadataKey marks the comment that closes a bug replaced by a // clone. Its value is the full ID of the replacement bug. const supersededByMetadataKey = "superseded-by" // supersededBy returns the full ID of the bug that replaced this one, or "". func supersededBy(snap *bug.Snapshot) string { for _, op := range snap.Operations { if _, ok := op.(*bug.AddCommentOperation); !ok { continue } if id, ok := op.GetMetadata(supersededByMetadataKey); ok && id != "" { return id } } return "" } // shortID abbreviates a full ID for display. func shortID(fullID string) string { if len(fullID) > 7 { return fullID[:7] } return fullID } // cloneBug re-creates a bug by replaying every operation of the original // with its original author and time, replacing the create-operation // metadata keys in overrides (e.g. parent, due). It is the only way to // change data git-bug stores immutably. The clone is committed; the // original is left untouched. func cloneBug(repo repository.ClockedRepo, original *bug.Bug, overrides map[string]string) (*bug.Bug, error) { snap := original.Compile() if len(snap.Operations) == 0 { return nil, fmt.Errorf("bug %s has no history to copy", shortID(original.Id().String())) } createID := snap.Operations[0].Id() clone := bug.NewBug() // Old operation ID -> new operation ID, for ops that target other ops newIDs := make(map[entity.Id]entity.Id, len(snap.Operations)) remap := func(target entity.Id) (entity.Id, error) { id, ok := newIDs[target] if !ok { return "", fmt.Errorf("bug %s references an operation that cannot be copied", shortID(original.Id().String())) } return id, nil } for _, op := range snap.Operations { author := op.Author() unixTime := op.Time().Unix() var copied bug.Operation var ownMetadata map[string]string switch o := op.(type) { case *bug.CreateOperation: c := bug.NewCreateOp(author, unixTime, o.Title, o.Message, o.Files) ownMetadata = withoutKeys(o.Metadata, overrides) for key, value := range overrides { ownMetadata[key] = value } copied = c case *bug.AddCommentOperation: copied = bug.NewAddCommentOp(author, unixTime, o.Message, o.Files) ownMetadata = o.Metadata case *bug.EditCommentOperation: target, err := remap(o.Target) if err != nil { return nil, err } copied = bug.NewEditCommentOp(author, unixTime, target, o.Message, o.Files) ownMetadata = o.Metadata case *bug.SetTitleOperation: copied = bug.NewSetTitleOp(author, unixTime, o.Title, o.Was) ownMetadata = o.Metadata case *bug.LabelChangeOperation: copied = bug.NewLabelChangeOperation(author, unixTime, o.Added, o.Removed) ownMetadata = o.Metadata case *bug.SetStatusOperation: copied = bug.NewSetStatusOp(author, unixTime, o.Status) ownMetadata = o.Metadata case *dag.SetMetadataOperation[*bug.Snapshot]: newMetadata := o.NewMetadata if o.Target == createID { // The clone carries overridden keys in the create operation newMetadata = withoutKeys(newMetadata, overrides) } if len(newMetadata) == 0 { continue } target, err := remap(o.Target) if err != nil { return nil, err } copied = bug.NewSetMetadataOp(author, unixTime, target, newMetadata) ownMetadata = o.Metadata case *dag.NoOpOperation[*bug.Snapshot]: copied = dag.NewNoOpOp[*bug.Snapshot](bug.NoOpOp, author, unixTime) ownMetadata = o.Metadata default: return nil, fmt.Errorf("bug %s contains a change of type %d that cannot be copied", shortID(original.Id().String()), op.Type()) } for key, value := range ownMetadata { copied.SetMetadata(key, value) } if err := copied.Validate(); err != nil { return nil, fmt.Errorf("failed to copy bug %s: %w", shortID(original.Id().String()), err) } clone.Append(copied) newIDs[op.Id()] = copied.Id() } if err := clone.Commit(repo); err != nil { return nil, fmt.Errorf("failed to save copy of bug %s: %w", shortID(original.Id().String()), err) } return clone, nil } // withoutKeys returns a copy of m without the keys of drop (never nil). func withoutKeys(m map[string]string, drop map[string]string) map[string]string { out := make(map[string]string, len(m)) for k, v := range m { if _, ok := drop[k]; !ok { out[k] = v } } return out } // supersedeBug closes the original bug with a note pointing at its clone. // reason completes "re-created there to change its ...". func supersedeBug(repo repository.ClockedRepo, original *bug.Bug, cloneFullID, reason string, author identity.Interface, unixTime int64) error { message := fmt.Sprintf("Superseded by %s: this bug was re-created there to change its %s. Continue work on %s.", shortID(cloneFullID), reason, shortID(cloneFullID)) metadata := map[string]string{supersededByMetadataKey: cloneFullID} if _, _, err := bug.AddComment(original, author, unixTime, message, nil, metadata); err != nil { return fmt.Errorf("failed to mark bug %s as superseded: %w", shortID(original.Id().String()), err) } if original.Compile().Status != common.ClosedStatus { if _, err := bug.Close(original, author, unixTime, nil); err != nil { return fmt.Errorf("failed to close superseded bug %s: %w", shortID(original.Id().String()), err) } } if err := original.Commit(repo); err != nil { return fmt.Errorf("failed to save superseded bug %s: %w", shortID(original.Id().String()), err) } return nil } // recreateBug re-creates root with its create metadata overridden by // rootOverrides. Because create metadata is immutable, the bug and all // its live descendants are cloned (descendants re-linked under their // cloned parents), and every original is closed as superseded. Returns // old full ID -> new full ID in the order bugs were cloned (root first). func recreateBug(repo repository.ClockedRepo, root *bug.Bug, rootOverrides map[string]string, reason string, issues []BugIssue, author identity.Interface, unixTime int64) ([][2]string, error) { children := make(map[string][]string) for _, issue := range issues { if issue.ParentID != "" && issue.SupersededBy == "" { children[issue.ParentID] = append(children[issue.ParentID], issue.FullID) } } var moved [][2]string var walk func(original *bug.Bug, overrides map[string]string) error walk = func(original *bug.Bug, overrides map[string]string) error { clone, err := cloneBug(repo, original, overrides) if err != nil { return err } oldID, newID := original.Id().String(), clone.Id().String() moved = append(moved, [2]string{oldID, newID}) for _, childID := range children[oldID] { child, err := bug.Read(repo, entity.Id(childID)) if err != nil { return fmt.Errorf("failed to read child bug %s: %w", shortID(childID), err) } if err := walk(child, map[string]string{parentMetadataKey: newID}); err != nil { return err } } // Supersede only after descendants are copied, so a failure part // way leaves the original subtree open and usable. childReason := reason if oldID != root.Id().String() { childReason = "parent" } return supersedeBug(repo, original, newID, childReason, author, unixTime) } if err := walk(root, rootOverrides); err != nil { return moved, err } return moved, nil }