diff --git a/README.md b/README.md index 271da0c..9b7e58a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Watcher TUI that shows changed files on the left and a side-by-side diff on the - `<`/`>` or `H`/`L`: adjust left pane width - `c`: open commit flow (overlay) - `q`: quit +- `V`: open revert wizard (revert to a specified commit `git revert ..HEAD`) The top bar shows `Changes | ` with a horizontal rule below. The bottom bar shows `h: help` on the left and the last `refreshed` time on the right. Requires `git` in PATH. Binary files are listed but not rendered as text diffs yet. diff --git a/internal/gitx/gitx.go b/internal/gitx/gitx.go index 7f57adc..aad8b84 100644 --- a/internal/gitx/gitx.go +++ b/internal/gitx/gitx.go @@ -8,6 +8,7 @@ import ( "path/filepath" "sort" "strings" + "time" ) // FileChange represents a changed file in the repo. @@ -20,6 +21,20 @@ type FileChange struct { Deleted bool } +// CommitInfo represents single git commit +type CommitInfo struct { + ID string + AbbreviatedHash string + Message string + AuthorEmail string + AuthorName string + AuthorDate time.Time + CommitterName string + CommitterEmail string + CommitterDate string + Body string +} + // RepoRoot resolves the git repository root from a given path (or current dir). func RepoRoot(path string) (string, error) { if path == "" { @@ -439,3 +454,103 @@ func CurrentBranch(repoRoot string) (string, error) { } return strings.TrimSpace(string(b)), nil } + +// ListCommits returns a slice of commits in reverse chronological order (latest first). +// The `from` and `to` parameters define a range of commits to fetch. This prevents +// loading thousands of commits at once, which could consume excessive memory. +// Example: ListCommits("/path/to/repo", 0, 50) retrieves the latest 50 commits. +func ListCommits(repoRoot string, from int, to int) ([]CommitInfo, error) { + if from < 0 || to < 0 || to < from { + return nil, fmt.Errorf("invalid range: from=%d, to=%d", from, to) + } + + const sep = "<--END-->" + format := strings.Join([]string{ + "%H", // full hash + "%s", // subject line + "%ae", // author email + "%an", // author name + "%ad", // author date + "%cn", // committer name + "%ce", // committer email + "%cd", // committer date + "%b", // body (commit description) + "%h", // abbreviated hash + }, "%n") + "%n" + sep + + count := to - from + + args := []string{ + "-C", repoRoot, + "log", + "--date=iso-strict", + "--skip", fmt.Sprint(from), + "-n", fmt.Sprint(count), + "--pretty=format:" + format, + } + + cmd := exec.Command("git", args...) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("list commits failed: %w", err) + } + + rawCommits := strings.Split(string(out), sep) + var commits []CommitInfo + + for _, raw := range rawCommits { + lines := strings.Split(strings.TrimSpace(raw), "\n") + if len(lines) < 10 { + continue + } + + authorDate, _ := time.Parse(time.RFC3339, strings.TrimSpace(lines[4])) + + commits = append(commits, CommitInfo{ + ID: lines[0], + Message: lines[1], + AuthorEmail: lines[2], + AuthorName: lines[3], + AuthorDate: authorDate, + CommitterName: lines[5], + CommitterEmail: lines[6], + CommitterDate: lines[7], + Body: strings.TrimSpace(strings.Join(lines[8:len(lines)-1], "\n")), + AbbreviatedHash: strings.TrimSpace(lines[len(lines)-1]), + }) + } + + return commits, nil +} + +// RevertBack reverts all commits made after the specified commit hash (exclusive). +// Internally, it runs `git revert --no-edit ..HEAD` to undo subsequent changes. +// Note: This creates new "revert" commits instead of rewriting history. +func RevertBack(repoRoot string, hash string) error { + if hash == "" { + return fmt.Errorf("hash cannot be empty") + } + + args := []string{ + "-C", repoRoot, + "revert", + "--no-edit", + hash + "..HEAD", + } + cmd := exec.Command("git", args...) + + b, err := cmd.Output() + + if err != nil { + return fmt.Errorf("git revert %s..HEAD: %w :%s", hash, err, string(b)) + } + + return nil +} + +// RevertPreview shows a what would change if the repository were reverted +// back to the specified commit. This runs a `git diff HEAD ` internally to +// preview the potential modifications before actually reverting. +func RevertPreview(repoRoot, hash string) ([]string, error) { + return listNames(repoRoot, []string{"diff", "HEAD", hash}) +} diff --git a/internal/tui/program.go b/internal/tui/program.go index d196e8c..5d34635 100644 --- a/internal/tui/program.go +++ b/internal/tui/program.go @@ -113,6 +113,16 @@ type model struct { searchQuery string searchMatches []int searchIndex int + + // revert wizard + showRevert bool + revertStep int // 0: list commits, 1: preview, 2: confirm + rwCommits []gitx.CommitInfo + rwSelected int + rwPreview []string // preview of what will be changed + reverting bool + revertErr string + revertDone bool } // messages @@ -176,7 +186,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.showPull { return m.handlePullKeys(msg) } - + if m.showRevert { + return m.handleRevertKeys(msg) + } key := msg.String() if isNumericKey(key) { @@ -214,6 +226,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Open reset/clean wizard m.openResetCleanWizard() return m, m.recalcViewport() + case "V": + // Open revert wizard + m.openRevertWizard() + return m, m.recalcViewport() case "/": (&m).openSearch() return m, m.recalcViewport() @@ -657,6 +673,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.uncommitDone = true m.showUncommit = false return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) + case revertResultMessage: + m.reverting = false + if msg.err != nil { + m.revertErr = msg.err.Error() + m.revertDone = false + return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) + } + m.revertErr = "" + m.revertDone = true + m.showRevert = false } return m, nil } @@ -722,6 +748,9 @@ func (m model) View() string { if m.showPull { overlay = append(overlay, m.pullOverlayLines(m.width)...) } + if m.showRevert { + overlay = append(overlay, m.revertOverlayLines(m.width)...) + } if m.searchActive { overlay = append(overlay, m.searchOverlayLines(m.width)...) } @@ -1137,6 +1166,7 @@ func (m model) helpOverlayLines(width int) []string { "u Uncommit (open wizard)", "R Reset/Clean (open wizard)", "c Commit & push (open wizard)", + "V Revert (open wizard)", "s Toggle side-by-side / inline", "t Toggle HEAD / staged diffs", "w Toggle line wrap (diff)", @@ -2849,3 +2879,153 @@ func isMovementKey(key string) bool { func isNumericKey(key string) bool { return key <= "9" && key >= "0" } + +// Revert Wizard + +// handle revert wizard keys +func (m model) handleRevertKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { + switch m.revertStep { + case 0: // list commits + switch key.String() { + case "esc": + m.showRevert = false + return m, m.recalcViewport() + case "enter": + m.revertStep = 1 + // load preview + m.loadRevertPreview() + return m, m.recalcViewport() + case "j", "down": + if len(m.rwCommits) > 0 && m.rwSelected < len(m.rwCommits)-1 { + m.rwSelected++ + } + return m, nil + case "k", "up": + if m.rwSelected > 0 { + m.rwSelected-- + } + return m, nil + } + case 1: // preview + switch key.String() { + case "esc": + m.showRevert = false + return m, m.recalcViewport() + case "b": + m.revertStep = 0 + return m, m.recalcViewport() + case "enter": + m.revertStep = 2 + m.revertDone = false + m.revertErr = "" + m.reverting = false + return m, m.recalcViewport() + } + case 2: // confirm/progress + switch key.String() { + case "esc": + // can't cancel mid-revert, but if not running: exit + if !m.reverting { + m.showRevert = false + return m, m.recalcViewport() + } + return m, nil + case "b": + if !m.reverting && !m.revertDone { + m.revertStep = 1 + return m, m.recalcViewport() + } + return m, nil + case "y", "enter": + if !m.reverting && !m.revertDone { + m.revertErr = "" + m.reverting = true + return m, runRevertBack(m.repoRoot, m.rwCommits[m.rwSelected].ID) + } + return m, nil + } + } + return m, nil +} + +func (m model) revertOverlayLines(width int) []string { + if !m.showRevert { + return nil + } + lines := make([]string, 0, 64) + lines = append(lines, strings.Repeat("─", width)) + switch m.revertStep { + case 0: + title := lipgloss.NewStyle().Bold(true).Render("Revert — Select commmit (enter: continue, esc: cancel)") + lines = append(lines, title) + if len(m.rwCommits) == 0 { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No commits to revert to.")) + return lines + } + for i, c := range m.rwCommits { + cur := " " + if i == m.rwSelected { + cur = "> " + } + mark := "[ ]" + if i == m.rwSelected { + mark = "[x]" + } + lines = append(lines, fmt.Sprintf("%s%s %s %s", cur, mark, c.AbbreviatedHash, c.Message)) + } + case 1: + title := lipgloss.NewStyle().Bold(true).Render("Revert — Preview (enter: continue, b: back, esc: cancel)") + lines = append(lines, title) + lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("220")).Render("The following changes will be done:")) + lines = append(lines, m.rwPreview...) + case 2: + title := lipgloss.NewStyle().Bold(true).Render("Revert - Confirm (y/enter: execute, b: back, esc: cancel)") + lines = append(lines, title) + lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("220")).Render(fmt.Sprintf("Please Confirm:"))) + if m.reverting { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Reverting...")) + } + if m.revertErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.revertErr) + } + } + return lines +} + +func (m *model) openRevertWizard() { + m.showRevert = true + // TODO: move to constant + commits, err := gitx.ListCommits(m.repoRoot, 0, 20) + if err != nil { + m.revertErr = err.Error() + } + m.rwCommits = commits + m.revertStep = 0 + m.rwSelected = 0 + m.revertErr = "" + m.revertDone = false + m.reverting = false +} + +func (m *model) loadRevertPreview() { + preview, err := gitx.RevertPreview(m.repoRoot, m.rwCommits[m.rwSelected].ID) + if err != nil { + m.revertErr = err.Error() + m.revertStep = 2 + return + } + m.rwPreview = preview +} + +type revertResultMessage struct { + err error +} + +func runRevertBack(repoRoot string, hash string) tea.Cmd { + return func() tea.Msg { + err := gitx.RevertBack(repoRoot, hash) + return revertResultMessage{ + err: err, + } + } +}