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:
Sam
2026-11-12 20:38:44 +11:00
parent f7b77f370f
commit c419de5b48
18 changed files with 2223 additions and 82 deletions

263
tui/gitinfo.go Normal file
View 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")
}