git: a creation gate that never writes history, a git tab with Paperclip's worktree scan, and a warning line in every pane
This commit is contained in:
263
tui/gitinfo.go
Normal file
263
tui/gitinfo.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package main
|
||||
|
||||
// What git says about the project folder, and what Paperclip says about the
|
||||
// worktree each task got.
|
||||
//
|
||||
// Two layers, deliberately:
|
||||
//
|
||||
// readGitState runs git in the folder. Paperclip does not check that the
|
||||
// folder is a repository at all, so this is the only early
|
||||
// warning a person can get.
|
||||
// readWorktrees asks Paperclip to scan each execution workspace. That scan
|
||||
// already knows the branch, what is dirty, how far behind the
|
||||
// base it is and whether it is merged — so nothing here is
|
||||
// recomputed.
|
||||
//
|
||||
// Every git command in this file runs inside a tea.Cmd, never in Update or View.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"paperclip-harness/tui/paperclip"
|
||||
)
|
||||
|
||||
// gitState is the folder as git sees it today.
|
||||
type gitState struct {
|
||||
IsRepo bool
|
||||
HasCommit bool
|
||||
Branch string
|
||||
Commits int
|
||||
Remotes []string
|
||||
Staged int
|
||||
Unstaged int
|
||||
Untracked int
|
||||
HasIgnore bool
|
||||
Ignore []string
|
||||
IgnoreErr string
|
||||
BehindBase int
|
||||
AheadBase int
|
||||
}
|
||||
|
||||
func gitOut(folder string, args ...string) (string, bool) {
|
||||
cmd := exec.Command("git", append([]string{"-C", folder}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimRight(string(out), "\n"), true
|
||||
}
|
||||
|
||||
// readGitState reads the folder. Safe to call from a goroutine.
|
||||
func readGitState(folder string) gitState {
|
||||
var g gitState
|
||||
if _, ok := gitOut(folder, "rev-parse", "--is-inside-work-tree"); !ok {
|
||||
return g
|
||||
}
|
||||
g.IsRepo = true
|
||||
if out, ok := gitOut(folder, "rev-parse", "--verify", "HEAD"); ok && out != "" {
|
||||
g.HasCommit = true
|
||||
}
|
||||
if out, ok := gitOut(folder, "symbolic-ref", "--short", "HEAD"); ok {
|
||||
g.Branch = out
|
||||
}
|
||||
if out, ok := gitOut(folder, "rev-list", "--count", "HEAD"); ok {
|
||||
g.Commits, _ = strconv.Atoi(strings.TrimSpace(out))
|
||||
}
|
||||
if out, ok := gitOut(folder, "remote"); ok && out != "" {
|
||||
g.Remotes = strings.Split(out, "\n")
|
||||
}
|
||||
if out, ok := gitOut(folder, "status", "--porcelain=v1"); ok {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if len(line) < 3 {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "??"):
|
||||
g.Untracked++
|
||||
case line[0] != ' ':
|
||||
g.Staged++
|
||||
default:
|
||||
g.Unstaged++
|
||||
}
|
||||
}
|
||||
}
|
||||
ignorePath := filepath.Join(folder, ".gitignore")
|
||||
if b, err := os.ReadFile(ignorePath); err == nil {
|
||||
g.HasIgnore = true
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
g.Ignore = append(g.Ignore, line)
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// worktreeRow is one task's directory, as Paperclip describes it.
|
||||
type worktreeRow struct {
|
||||
Issue string
|
||||
Branch string
|
||||
Path string
|
||||
Dirty int
|
||||
Ahead int
|
||||
Behind int
|
||||
Merged bool
|
||||
State string
|
||||
Warning string
|
||||
}
|
||||
|
||||
// readWorktrees asks Paperclip for this project's task workspaces and their git
|
||||
// scan. Newest first, and only the ones still in use, because each row costs a
|
||||
// request and an old number is not worth one.
|
||||
func readWorktrees(c *paperclip.Client, projectID string, limit int) ([]worktreeRow, error) {
|
||||
all, err := c.ExecutionWorkspaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var mine []paperclip.ExecutionWorkspace
|
||||
for _, w := range all {
|
||||
if w.ProjectID != projectID {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(w.Status, "closed") || strings.EqualFold(w.Status, "archived") {
|
||||
continue
|
||||
}
|
||||
mine = append(mine, w)
|
||||
}
|
||||
sort.SliceStable(mine, func(i, j int) bool { return mine[i].LastUsedAt > mine[j].LastUsedAt })
|
||||
if limit > 0 && len(mine) > limit {
|
||||
mine = mine[:limit]
|
||||
}
|
||||
out := make([]worktreeRow, 0, len(mine))
|
||||
for _, w := range mine {
|
||||
row := worktreeRow{Branch: w.BranchName, Path: w.ProviderRef}
|
||||
if row.Path == "" {
|
||||
row.Path = w.Cwd
|
||||
}
|
||||
if rr, err := c.CloseReadinessFor(w.ID); err == nil {
|
||||
row.Branch = rr.Git.BranchName
|
||||
if rr.Git.WorkspacePath != "" {
|
||||
row.Path = rr.Git.WorkspacePath
|
||||
}
|
||||
row.Dirty = rr.Git.DirtyEntryCount + rr.Git.UntrackedEntryCount
|
||||
row.Ahead = rr.Git.AheadCount
|
||||
row.Behind = rr.Git.BehindCount
|
||||
row.Merged = rr.Git.IsMergedIntoBase
|
||||
row.State = rr.State
|
||||
if len(rr.Warnings) > 0 {
|
||||
row.Warning = rr.Warnings[0]
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ready reports whether this folder can give each task its own worktree.
|
||||
func (g gitState) ready() bool { return g.IsRepo && g.HasCommit }
|
||||
|
||||
// ignoresPaperclipWorktrees finds the one entry that must never be missing: the
|
||||
// folder Paperclip puts worktrees in. Without it a first commit swallows them.
|
||||
func (g gitState) ignoresPaperclipWorktrees() bool {
|
||||
for _, p := range g.Ignore {
|
||||
trimmed := strings.Trim(p, "/")
|
||||
if trimmed == ".paperclip" || strings.HasPrefix(trimmed, ".paperclip/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// banner is the one line shown in every pane while this folder cannot hold a
|
||||
// task worktree. Empty when it can.
|
||||
func bannerFor(g gitState) string {
|
||||
if g.ready() && g.ignoresPaperclipWorktrees() {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case !g.IsRepo:
|
||||
return "no worktrees — this folder is not a git repository · Project>project → git"
|
||||
case !g.HasCommit:
|
||||
return "no worktrees — this repository has no commit · Project>project → git"
|
||||
default:
|
||||
return "commit will swallow the worktrees — .paperclip/ is not in .gitignore · Project>project → git"
|
||||
}
|
||||
}
|
||||
|
||||
// bannerText is set once at startup, before the terminal is taken over, so no
|
||||
// git command ever runs inside Update or View. Fixing the folder and pressing R
|
||||
// (which restarts every pane) is what clears it.
|
||||
var bannerText string
|
||||
|
||||
func setGitBanner(folder string) { bannerText = bannerFor(readGitState(folder)) }
|
||||
|
||||
// today is here so the file has one small helper used by the tab, and so the
|
||||
// tab and the banner cannot drift apart in how they describe the same thing.
|
||||
func today() string { return time.Now().Format("15:04") }
|
||||
|
||||
// bannerLine is one full-width line, cut rather than wrapped.
|
||||
func bannerLine(text string, width int) string {
|
||||
if width <= 0 {
|
||||
return ""
|
||||
}
|
||||
if lipgloss.Width(text) > width {
|
||||
text = truncateVisible(text, width)
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Foreground(lipgloss.Color(active.Warning)).
|
||||
Background(lipgloss.Color(active.Surface)).
|
||||
Render(text)
|
||||
}
|
||||
|
||||
// withBanner wraps a screen so that the setup warning appears once, at the top,
|
||||
// whatever the screen is made of. The board draws three panels side by side, so
|
||||
// drawing the banner inside a panel put it there three times.
|
||||
//
|
||||
// It takes the line the screen would have drawn first — the page title and the
|
||||
// read time — and gives that line to the warning. The panels and the key hints
|
||||
// are left alone, because those are what a person needs while fixing the
|
||||
// problem.
|
||||
type bannerModel struct {
|
||||
inner tea.Model
|
||||
width, height int
|
||||
}
|
||||
|
||||
func (b bannerModel) Init() tea.Cmd { return b.inner.Init() }
|
||||
|
||||
func (b bannerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if w, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
b.width, b.height = w.Width, w.Height
|
||||
}
|
||||
next, cmd := b.inner.Update(msg)
|
||||
b.inner = next
|
||||
return b, cmd
|
||||
}
|
||||
|
||||
func (b bannerModel) View() string {
|
||||
view := b.inner.View()
|
||||
if bannerText == "" || b.width <= 0 {
|
||||
return view
|
||||
}
|
||||
lines := strings.Split(view, "\n")
|
||||
if len(lines) > 1 {
|
||||
lines = lines[1:] // the warning takes the title line
|
||||
}
|
||||
out := append([]string{bannerLine(bannerText, b.width)}, lines...)
|
||||
if b.height > 0 && len(out) > b.height {
|
||||
out = out[:b.height]
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
Reference in New Issue
Block a user