Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <commit_hash>..HEAD`)

The top bar shows `Changes | <file>` 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.

Expand Down
115 changes: 115 additions & 0 deletions internal/gitx/gitx.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
)

// FileChange represents a changed file in the repo.
Expand All @@ -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 == "" {
Expand Down Expand Up @@ -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 <hash>..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 <hash>` internally to
// preview the potential modifications before actually reverting.
func RevertPreview(repoRoot, hash string) ([]string, error) {
return listNames(repoRoot, []string{"diff", "HEAD", hash})
}
182 changes: 181 additions & 1 deletion internal/tui/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)...)
}
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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,
}
}
}