Fix transcript duplication after viewport reflow
- commit
- b289bf69d5e11390b1590db644cfcceb9a29e588
- author
- patriceckhart · · 2026-07-30 06:04 UTC
- parent
- 1c2b2f0
- signature
- unsigned
- refs
- tag: v0.3.26
2 files · +104 -45
M packages/tui/render.go +77 -32
| @@ -629,53 +629,98 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 629 | 629 | // history. Replaying the full logical buffer to update one of them | |
| 630 | 630 | // duplicates that history, and erasing it first destroys native text | |
| 631 | 631 | // selections. Ignore inaccessible changes and patch only the first | |
| 632 | - | // changed row that is still addressable. The cached logical buffer is | |
| 633 | - | // updated below, so ignored rows remain historical snapshots while new | |
| 634 | - | // output naturally pushes them farther into scrollback. | |
| 632 | + | // changed row that is still addressable. | |
| 635 | 633 | if firstChanged >= 0 && firstChanged < r.logViewportTop { | |
| 636 | - | // A height change above the viewport shifts the logical row numbers | |
| 637 | - | // of everything below it. If any already-emitted visible row also | |
| 638 | - | // changed, an index-based patch would target the wrong physical row | |
| 639 | - | // and splice the next tool frame into the previous one's border. | |
| 640 | - | // Recover with a clean repaint. Pure suffix appends remain safe and | |
| 641 | - | // keep terminal selections intact. | |
| 642 | - | structuralReflow := len(chatFrame) < len(r.logChat) | |
| 643 | - | if len(chatFrame) != len(r.logChat) { | |
| 634 | + | delta := len(chatFrame) - len(r.logChat) | |
| 635 | + | reflowAbove := false | |
| 636 | + | if delta != 0 { | |
| 644 | 637 | overlapEnd := len(chatFrame) | |
| 645 | 638 | if len(r.logChat) < overlapEnd { | |
| 646 | 639 | overlapEnd = len(r.logChat) | |
| 647 | 640 | } | |
| 648 | 641 | for idx := r.logViewportTop; idx < overlapEnd; idx++ { | |
| 649 | 642 | if r.logChat[idx] != chatFrame[idx] { | |
| 650 | - | structuralReflow = true | |
| 643 | + | reflowAbove = true | |
| 651 | 644 | break | |
| 652 | 645 | } | |
| 653 | 646 | } | |
| 654 | 647 | } | |
| 655 | - | if structuralReflow { | |
| 656 | - | writeFull(true, false) | |
| 657 | - | firstChanged = -1 | |
| 658 | - | } else { | |
| 659 | - | firstChanged = -1 | |
| 660 | - | for idx := r.logViewportTop; idx < maxLines; idx++ { | |
| 661 | - | oldLine := "" | |
| 662 | - | if idx < len(r.logLines) { | |
| 663 | - | oldLine = r.logLines[idx] | |
| 664 | - | } | |
| 665 | - | newLine := "" | |
| 666 | - | if idx < len(lines) { | |
| 667 | - | newLine = lines[idx] | |
| 648 | + | if reflowAbove { | |
| 649 | + | // A height change above the viewport shifts every following | |
| 650 | + | // logical row, but it does not move the rows already painted by | |
| 651 | + | // the terminal. Rebase our logical coordinates by the same delta | |
| 652 | + | // instead of clearing and replaying the transcript. The inaccessible | |
| 653 | + | // prefix remains the historical snapshot the terminal actually | |
| 654 | + | // holds; addressable chat and bottom rows stay aligned with their | |
| 655 | + | // existing physical rows. | |
| 656 | + | oldLines := r.logLines | |
| 657 | + | oldChatLen := len(r.logChat) | |
| 658 | + | newViewportTop := r.logViewportTop + delta | |
| 659 | + | if newViewportTop < 0 { | |
| 660 | + | newViewportTop = 0 | |
| 661 | + | } | |
| 662 | + | rebasedLen := len(oldLines) + delta | |
| 663 | + | if rebasedLen < 0 { | |
| 664 | + | rebasedLen = 0 | |
| 665 | + | } | |
| 666 | + | rebased := make([]string, rebasedLen) | |
| 667 | + | for idx := range rebased { | |
| 668 | + | switch { | |
| 669 | + | case idx < newViewportTop: | |
| 670 | + | // These rows cannot be repainted. Mark the new logical | |
| 671 | + | // contents as accepted so they do not trigger a replay. | |
| 672 | + | if idx < len(lines) { | |
| 673 | + | rebased[idx] = lines[idx] | |
| 674 | + | } | |
| 675 | + | case idx < len(chatFrame): | |
| 676 | + | oldIdx := idx - delta | |
| 677 | + | if oldIdx >= 0 && oldIdx < oldChatLen { | |
| 678 | + | rebased[idx] = oldLines[oldIdx] | |
| 679 | + | } | |
| 680 | + | default: | |
| 681 | + | // Bottom rows are indexed relative to the end of chat, | |
| 682 | + | // so preserve that relative position across the reflow. | |
| 683 | + | oldIdx := oldChatLen + idx - len(chatFrame) | |
| 684 | + | if oldIdx >= 0 && oldIdx < len(oldLines) { | |
| 685 | + | rebased[idx] = oldLines[oldIdx] | |
| 686 | + | } | |
| 668 | 687 | } | |
| 669 | - | if oldLine != newLine { | |
| 688 | + | } | |
| 689 | + | r.logLines = rebased | |
| 690 | + | r.logViewportTop = newViewportTop | |
| 691 | + | r.logHardwareRow += delta | |
| 692 | + | if r.logHardwareRow < 0 { | |
| 693 | + | r.logHardwareRow = 0 | |
| 694 | + | } | |
| 695 | + | maxLines = len(lines) | |
| 696 | + | if len(r.logLines) > maxLines { | |
| 697 | + | maxLines = len(r.logLines) | |
| 698 | + | } | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | firstChanged = -1 | |
| 702 | + | lastChanged = -1 | |
| 703 | + | for idx := r.logViewportTop; idx < maxLines; idx++ { | |
| 704 | + | oldLine := "" | |
| 705 | + | if idx < len(r.logLines) { | |
| 706 | + | oldLine = r.logLines[idx] | |
| 707 | + | } | |
| 708 | + | newLine := "" | |
| 709 | + | if idx < len(lines) { | |
| 710 | + | newLine = lines[idx] | |
| 711 | + | } | |
| 712 | + | if oldLine != newLine { | |
| 713 | + | if firstChanged == -1 { | |
| 670 | 714 | firstChanged = idx | |
| 671 | - | break | |
| 672 | 715 | } | |
| 716 | + | lastChanged = idx | |
| 673 | 717 | } | |
| 674 | - | // A newly appended blank row compares equal to the implicit empty | |
| 675 | - | // row past the old slice, but it still has to advance the terminal. | |
| 676 | - | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | |
| 677 | - | firstChanged = len(r.logLines) | |
| 678 | - | } | |
| 718 | + | } | |
| 719 | + | // A newly appended blank row compares equal to the implicit empty | |
| 720 | + | // row past the old slice, but it still has to advance the terminal. | |
| 721 | + | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | |
| 722 | + | firstChanged = len(r.logLines) | |
| 723 | + | lastChanged = len(lines) - 1 | |
| 679 | 724 | } | |
| 680 | 725 | } | |
| 681 | 726 | ||
| @@ -629,53 +629,98 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 629 | // history. Replaying the full logical buffer to update one of them | 629 | // history. Replaying the full logical buffer to update one of them | ||
| 630 | // duplicates that history, and erasing it first destroys native text | 630 | // duplicates that history, and erasing it first destroys native text | ||
| 631 | // selections. Ignore inaccessible changes and patch only the first | 631 | // selections. Ignore inaccessible changes and patch only the first | ||
| 632 | - | // changed row that is still addressable. The cached logical buffer is | 632 | + | // changed row that is still addressable. |
| 633 | - | // updated below, so ignored rows remain historical snapshots while new | |||
| 634 | - | // output naturally pushes them farther into scrollback. | |||
| 635 | if firstChanged >= 0 && firstChanged < r.logViewportTop { | 633 | if firstChanged >= 0 && firstChanged < r.logViewportTop { | ||
| 636 | - | // A height change above the viewport shifts the logical row numbers | 634 | + | delta := len(chatFrame) - len(r.logChat) |
| 637 | - | // of everything below it. If any already-emitted visible row also | 635 | + | reflowAbove := false |
| 638 | - | // changed, an index-based patch would target the wrong physical row | 636 | + | if delta != 0 { |
| 639 | - | // and splice the next tool frame into the previous one's border. | |||
| 640 | - | // Recover with a clean repaint. Pure suffix appends remain safe and | |||
| 641 | - | // keep terminal selections intact. | |||
| 642 | - | structuralReflow := len(chatFrame) < len(r.logChat) | |||
| 643 | - | if len(chatFrame) != len(r.logChat) { | |||
| 644 | overlapEnd := len(chatFrame) | 637 | overlapEnd := len(chatFrame) | ||
| 645 | if len(r.logChat) < overlapEnd { | 638 | if len(r.logChat) < overlapEnd { | ||
| 646 | overlapEnd = len(r.logChat) | 639 | overlapEnd = len(r.logChat) | ||
| 647 | } | 640 | } | ||
| 648 | for idx := r.logViewportTop; idx < overlapEnd; idx++ { | 641 | for idx := r.logViewportTop; idx < overlapEnd; idx++ { | ||
| 649 | if r.logChat[idx] != chatFrame[idx] { | 642 | if r.logChat[idx] != chatFrame[idx] { | ||
| 650 | - | structuralReflow = true | 643 | + | reflowAbove = true |
| 651 | break | 644 | break | ||
| 652 | } | 645 | } | ||
| 653 | } | 646 | } | ||
| 654 | } | 647 | } | ||
| 655 | - | if structuralReflow { | 648 | + | if reflowAbove { |
| 656 | - | writeFull(true, false) | 649 | + | // A height change above the viewport shifts every following |
| 657 | - | firstChanged = -1 | 650 | + | // logical row, but it does not move the rows already painted by |
| 658 | - | } else { | 651 | + | // the terminal. Rebase our logical coordinates by the same delta |
| 659 | - | firstChanged = -1 | 652 | + | // instead of clearing and replaying the transcript. The inaccessible |
| 660 | - | for idx := r.logViewportTop; idx < maxLines; idx++ { | 653 | + | // prefix remains the historical snapshot the terminal actually |
| 661 | - | oldLine := "" | 654 | + | // holds; addressable chat and bottom rows stay aligned with their |
| 662 | - | if idx < len(r.logLines) { | 655 | + | // existing physical rows. |
| 663 | - | oldLine = r.logLines[idx] | 656 | + | oldLines := r.logLines |
| 664 | - | } | 657 | + | oldChatLen := len(r.logChat) |
| 665 | - | newLine := "" | 658 | + | newViewportTop := r.logViewportTop + delta |
| 666 | - | if idx < len(lines) { | 659 | + | if newViewportTop < 0 { |
| 667 | - | newLine = lines[idx] | 660 | + | newViewportTop = 0 |
| 661 | + | } | |||
| 662 | + | rebasedLen := len(oldLines) + delta | |||
| 663 | + | if rebasedLen < 0 { | |||
| 664 | + | rebasedLen = 0 | |||
| 665 | + | } | |||
| 666 | + | rebased := make([]string, rebasedLen) | |||
| 667 | + | for idx := range rebased { | |||
| 668 | + | switch { | |||
| 669 | + | case idx < newViewportTop: | |||
| 670 | + | // These rows cannot be repainted. Mark the new logical | |||
| 671 | + | // contents as accepted so they do not trigger a replay. | |||
| 672 | + | if idx < len(lines) { | |||
| 673 | + | rebased[idx] = lines[idx] | |||
| 674 | + | } | |||
| 675 | + | case idx < len(chatFrame): | |||
| 676 | + | oldIdx := idx - delta | |||
| 677 | + | if oldIdx >= 0 && oldIdx < oldChatLen { | |||
| 678 | + | rebased[idx] = oldLines[oldIdx] | |||
| 679 | + | } | |||
| 680 | + | default: | |||
| 681 | + | // Bottom rows are indexed relative to the end of chat, | |||
| 682 | + | // so preserve that relative position across the reflow. | |||
| 683 | + | oldIdx := oldChatLen + idx - len(chatFrame) | |||
| 684 | + | if oldIdx >= 0 && oldIdx < len(oldLines) { | |||
| 685 | + | rebased[idx] = oldLines[oldIdx] | |||
| 686 | + | } | |||
| 668 | } | 687 | } | ||
| 669 | - | if oldLine != newLine { | 688 | + | } |
| 689 | + | r.logLines = rebased | |||
| 690 | + | r.logViewportTop = newViewportTop | |||
| 691 | + | r.logHardwareRow += delta | |||
| 692 | + | if r.logHardwareRow < 0 { | |||
| 693 | + | r.logHardwareRow = 0 | |||
| 694 | + | } | |||
| 695 | + | maxLines = len(lines) | |||
| 696 | + | if len(r.logLines) > maxLines { | |||
| 697 | + | maxLines = len(r.logLines) | |||
| 698 | + | } | |||
| 699 | + | } | |||
| 700 | + | ||||
| 701 | + | firstChanged = -1 | |||
| 702 | + | lastChanged = -1 | |||
| 703 | + | for idx := r.logViewportTop; idx < maxLines; idx++ { | |||
| 704 | + | oldLine := "" | |||
| 705 | + | if idx < len(r.logLines) { | |||
| 706 | + | oldLine = r.logLines[idx] | |||
| 707 | + | } | |||
| 708 | + | newLine := "" | |||
| 709 | + | if idx < len(lines) { | |||
| 710 | + | newLine = lines[idx] | |||
| 711 | + | } | |||
| 712 | + | if oldLine != newLine { | |||
| 713 | + | if firstChanged == -1 { | |||
| 670 | firstChanged = idx | 714 | firstChanged = idx | ||
| 671 | - | break | |||
| 672 | } | 715 | } | ||
| 716 | + | lastChanged = idx | |||
| 673 | } | 717 | } | ||
| 674 | - | // A newly appended blank row compares equal to the implicit empty | 718 | + | } |
| 675 | - | // row past the old slice, but it still has to advance the terminal. | 719 | + | // A newly appended blank row compares equal to the implicit empty |
| 676 | - | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | 720 | + | // row past the old slice, but it still has to advance the terminal. |
| 677 | - | firstChanged = len(r.logLines) | 721 | + | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { |
| 678 | - | } | 722 | + | firstChanged = len(r.logLines) |
| 723 | + | lastChanged = len(lines) - 1 | |||
| 679 | } | 724 | } | ||
| 680 | } | 725 | } | ||
| 681 | 726 | ||||
M packages/tui/render_test.go +27 -13
| @@ -126,37 +126,51 @@ func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) { | |||
| 126 | 126 | } | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | - | func TestDrawLogStructuralReflowAboveViewportRepaintsToolFrames(t *testing.T) { | |
| 130 | - | t.Setenv("TERM_PROGRAM", "") | |
| 129 | + | func TestDrawLogStructuralReflowAboveViewportRebasesWithoutReplay(t *testing.T) { | |
| 130 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 131 | 131 | var buf bytes.Buffer | |
| 132 | 132 | r := NewRenderer(&buf) | |
| 133 | 133 | r.Resize(80, 4) | |
| 134 | 134 | oldChat := []string{ | |
| 135 | - | "┌ bash first ─────", "│", "│ old output", "└──────────────────", | |
| 135 | + | "user prompt", | |
| 136 | + | "┌ bash first ─────", "│ old output", "└──────────────────", | |
| 136 | 137 | "┌ bash second ────", "│ second output", "└──────────────────", | |
| 137 | 138 | } | |
| 138 | 139 | r.DrawLog(oldChat, []string{"input"}, 0, 0) | |
| 139 | 140 | buf.Reset() | |
| 140 | 141 | ||
| 141 | 142 | // The first tool gains wrapped rows after its top has scrolled away. | |
| 142 | - | // Every following logical row shifts, so patching by the old indexes | |
| 143 | - | // would draw the second header inside the first tool's border. | |
| 143 | + | // Clearing the viewport and replaying the complete logical transcript | |
| 144 | + | // leaves the old prompt in native scrollback and prints a second copy. | |
| 145 | + | // Rebase the logical row coordinates instead, leaving inaccessible rows | |
| 146 | + | // as historical snapshots and keeping the visible tail aligned. | |
| 144 | 147 | newChat := []string{ | |
| 145 | - | "┌ bash first ─────", "│", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────", | |
| 148 | + | "user prompt", | |
| 149 | + | "┌ bash first ─────", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────", | |
| 146 | 150 | "┌ bash second ────", "│ second output", "└──────────────────", | |
| 147 | 151 | } | |
| 148 | 152 | r.DrawLog(newChat, []string{"input"}, 0, 0) | |
| 149 | 153 | got := buf.String() | |
| 150 | - | if !strings.Contains(got, SeqClearScreenNoHome) { | |
| 151 | - | t.Fatalf("structural reflow was patched in place instead of repainted: %q", got) | |
| 154 | + | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { | |
| 155 | + | t.Fatalf("structural reflow cleared the terminal: %q", got) | |
| 152 | 156 | } | |
| 153 | - | for _, want := range []string{"│ wrapped line 2", "┌ bash second", "└──────────────────"} { | |
| 154 | - | if !strings.Contains(got, want) { | |
| 155 | - | t.Fatalf("repaint missing %q: %q", want, got) | |
| 157 | + | for _, replayed := range []string{"user prompt", "┌ bash first", "┌ bash second"} { | |
| 158 | + | if strings.Contains(got, replayed) { | |
| 159 | + | t.Fatalf("structural reflow replayed %q into scrollback: %q", replayed, got) | |
| 156 | 160 | } | |
| 157 | 161 | } | |
| 158 | - | if strings.Contains(got, SeqClearScrollback) { | |
| 159 | - | t.Fatalf("automatic recovery erased terminal scrollback: %q", got) | |
| 162 | + | ||
| 163 | + | buf.Reset() | |
| 164 | + | appended := append(append([]string(nil), newChat...), "┌ bash third ─────", "│ third output", "└──────────────────") | |
| 165 | + | r.DrawLog(appended, []string{"input"}, 0, 0) | |
| 166 | + | got = buf.String() | |
| 167 | + | if !strings.Contains(got, "┌ bash third") || !strings.Contains(got, "│ third output") { | |
| 168 | + | t.Fatalf("new output was not appended after coordinate rebase: %q", got) | |
| 169 | + | } | |
| 170 | + | for _, replayed := range []string{"user prompt", "┌ bash first", "┌ bash second"} { | |
| 171 | + | if strings.Contains(got, replayed) { | |
| 172 | + | t.Fatalf("append after rebase replayed %q: %q", replayed, got) | |
| 173 | + | } | |
| 160 | 174 | } | |
| 161 | 175 | } | |
| 162 | 176 | ||
| @@ -126,37 +126,51 @@ func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) { | |||||
| 126 | } | 126 | } | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | - | func TestDrawLogStructuralReflowAboveViewportRepaintsToolFrames(t *testing.T) { | 129 | + | func TestDrawLogStructuralReflowAboveViewportRebasesWithoutReplay(t *testing.T) { |
| 130 | - | t.Setenv("TERM_PROGRAM", "") | 130 | + | t.Setenv("TERM_PROGRAM", "ghostty") |
| 131 | var buf bytes.Buffer | 131 | var buf bytes.Buffer | ||
| 132 | r := NewRenderer(&buf) | 132 | r := NewRenderer(&buf) | ||
| 133 | r.Resize(80, 4) | 133 | r.Resize(80, 4) | ||
| 134 | oldChat := []string{ | 134 | oldChat := []string{ | ||
| 135 | - | "┌ bash first ─────", "│", "│ old output", "└──────────────────", | 135 | + | "user prompt", |
| 136 | + | "┌ bash first ─────", "│ old output", "└──────────────────", | |||
| 136 | "┌ bash second ────", "│ second output", "└──────────────────", | 137 | "┌ bash second ────", "│ second output", "└──────────────────", | ||
| 137 | } | 138 | } | ||
| 138 | r.DrawLog(oldChat, []string{"input"}, 0, 0) | 139 | r.DrawLog(oldChat, []string{"input"}, 0, 0) | ||
| 139 | buf.Reset() | 140 | buf.Reset() | ||
| 140 | 141 | ||||
| 141 | // The first tool gains wrapped rows after its top has scrolled away. | 142 | // The first tool gains wrapped rows after its top has scrolled away. | ||
| 142 | - | // Every following logical row shifts, so patching by the old indexes | 143 | + | // Clearing the viewport and replaying the complete logical transcript |
| 143 | - | // would draw the second header inside the first tool's border. | 144 | + | // leaves the old prompt in native scrollback and prints a second copy. |
| 145 | + | // Rebase the logical row coordinates instead, leaving inaccessible rows | |||
| 146 | + | // as historical snapshots and keeping the visible tail aligned. | |||
| 144 | newChat := []string{ | 147 | newChat := []string{ | ||
| 145 | - | "┌ bash first ─────", "│", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────", | 148 | + | "user prompt", |
| 149 | + | "┌ bash first ─────", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────", | |||
| 146 | "┌ bash second ────", "│ second output", "└──────────────────", | 150 | "┌ bash second ────", "│ second output", "└──────────────────", | ||
| 147 | } | 151 | } | ||
| 148 | r.DrawLog(newChat, []string{"input"}, 0, 0) | 152 | r.DrawLog(newChat, []string{"input"}, 0, 0) | ||
| 149 | got := buf.String() | 153 | got := buf.String() | ||
| 150 | - | if !strings.Contains(got, SeqClearScreenNoHome) { | 154 | + | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { |
| 151 | - | t.Fatalf("structural reflow was patched in place instead of repainted: %q", got) | 155 | + | t.Fatalf("structural reflow cleared the terminal: %q", got) |
| 152 | } | 156 | } | ||
| 153 | - | for _, want := range []string{"│ wrapped line 2", "┌ bash second", "└──────────────────"} { | 157 | + | for _, replayed := range []string{"user prompt", "┌ bash first", "┌ bash second"} { |
| 154 | - | if !strings.Contains(got, want) { | 158 | + | if strings.Contains(got, replayed) { |
| 155 | - | t.Fatalf("repaint missing %q: %q", want, got) | 159 | + | t.Fatalf("structural reflow replayed %q into scrollback: %q", replayed, got) |
| 156 | } | 160 | } | ||
| 157 | } | 161 | } | ||
| 158 | - | if strings.Contains(got, SeqClearScrollback) { | 162 | + | |
| 159 | - | t.Fatalf("automatic recovery erased terminal scrollback: %q", got) | 163 | + | buf.Reset() |
| 164 | + | appended := append(append([]string(nil), newChat...), "┌ bash third ─────", "│ third output", "└──────────────────") | |||
| 165 | + | r.DrawLog(appended, []string{"input"}, 0, 0) | |||
| 166 | + | got = buf.String() | |||
| 167 | + | if !strings.Contains(got, "┌ bash third") || !strings.Contains(got, "│ third output") { | |||
| 168 | + | t.Fatalf("new output was not appended after coordinate rebase: %q", got) | |||
| 169 | + | } | |||
| 170 | + | for _, replayed := range []string{"user prompt", "┌ bash first", "┌ bash second"} { | |||
| 171 | + | if strings.Contains(got, replayed) { | |||
| 172 | + | t.Fatalf("append after rebase replayed %q: %q", replayed, got) | |||
| 173 | + | } | |||
| 160 | } | 174 | } | ||
| 161 | } | 175 | } | ||
| 162 | 176 | ||||