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

View File

@@ -8,6 +8,7 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
@@ -56,3 +57,58 @@ func TestTruncateVisibleKeepsStylingAndWidth(t *testing.T) {
t.Fatalf("truncation should say it truncated: %q", got)
}
}
func TestBannerIsDrawnOnceAndKeepsTheHeight(t *testing.T) {
defer func(prev string) { bannerText = prev }(bannerText)
bannerText = "no worktrees — this folder is not a git repository"
const w, h = 80, 24
// A screen with three panels side by side, the way the board is drawn.
one := panel{title: "a", width: 26, height: h - 2}.render([]string{"x"})
two := panel{title: "b", width: 26, height: h - 2}.render([]string{"y"})
three := panel{title: "c", width: 26, height: h - 2}.render([]string{"z"})
inner := strings.Join([]string{
strings.Repeat(" ", w),
sideBySide(one, two, three),
"footer",
}, "\n")
b := bannerModel{inner: stubModel{view: inner}, width: w, height: h}
got := lines(b.View())
if len(got) != h {
t.Fatalf("got %d lines, want %d", len(got), h)
}
if n := strings.Count(strings.Join(got, "\n"), "no worktrees"); n != 1 {
t.Fatalf("the warning appears %d times, want once", n)
}
if !strings.Contains(strings.Join(got, "\n"), "footer") {
t.Fatal("the key hints were dropped; only the title line should give way")
}
if lipgloss.Width(got[0]) != w {
t.Fatalf("the warning line is %d wide, want %d", lipgloss.Width(got[0]), w)
}
}
type stubModel struct{ view string }
func (s stubModel) Init() tea.Cmd { return nil }
func (s stubModel) Update(tea.Msg) (tea.Model, tea.Cmd) { return s, nil }
func (s stubModel) View() string { return s.view }
func sideBySide(parts ...string) string {
rows := make([][]string, len(parts))
for i, p := range parts {
rows[i] = lines(p)
}
var out []string
for r := range rows[0] {
var b strings.Builder
for _, cols := range rows {
if r < len(cols) {
b.WriteString(cols[r])
}
}
out = append(out, b.String())
}
return strings.Join(out, "\n")
}

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")
}

View File

@@ -40,6 +40,14 @@ func main() {
fmt.Fprintln(os.Stderr, "warning: PAPERCLIP_BOARD_KEY is not set, so replies and status changes will be refused")
}
// One git read, here, before the terminal is taken over: the banner in every
// pane depends on it and no git command may run inside Update or View.
screenFolder := *folder
if screenFolder == "" {
screenFolder, _ = os.Getwd()
}
setGitBanner(screenFolder)
var m tea.Model
switch *screen {
case "board":
@@ -138,7 +146,8 @@ func main() {
// Alternate screen: TUIOS keeps its own scrollback, and a full-screen
// program that redraws in place must not fight it.
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
// The setup warning is drawn once for the whole screen, not once per panel.
p := tea.NewProgram(bannerModel{inner: m}, tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)

View File

@@ -221,6 +221,23 @@ type Project struct {
Name string `json:"name"`
URLKey string `json:"urlKey"`
Status string `json:"status"`
// The worktree policy is fixed when the project is created, so the screen
// reports it rather than offering to change it.
ExecutionWorkspacePolicy *struct {
Enabled bool `json:"enabled"`
DefaultMode string `json:"defaultMode"`
Strategy struct {
Type string `json:"type"`
Base string `json:"baseRef"`
} `json:"workspaceStrategy"`
} `json:"executionWorkspacePolicy"`
Workspaces []struct {
SourceType string `json:"sourceType"`
Cwd string `json:"cwd"`
DefaultRef string `json:"defaultRef"`
} `json:"workspaces"`
}
// Comment is one entry on a task's thread.
@@ -615,3 +632,76 @@ func (c *Client) ImportSkill(source string) (string, error) {
}
return "", fmt.Errorf("import returned no skill")
}
// ExecutionWorkspace is one task's own directory: a git worktree, or a shared
// folder when the project does not use worktrees. Paperclip makes these, not us.
type ExecutionWorkspace struct {
ID string `json:"id"`
Name string `json:"name"`
ProjectID string `json:"projectId"`
SourceIssueID string `json:"sourceIssueId"`
BranchName string `json:"branchName"`
BaseRef string `json:"baseRef"`
ProviderType string `json:"providerType"`
ProviderRef string `json:"providerRef"`
Mode string `json:"mode"`
Status string `json:"status"`
DeliveryState string `json:"deliveryState"`
Cwd string `json:"cwd"`
LastUsedAt string `json:"lastUsedAt"`
}
// CloseReadiness is Paperclip's own git scan of one execution workspace, plus
// what it would do if the workspace were closed. The git half is the only place
// in Paperclip that reports branch, dirtiness and how far behind a worktree is.
type CloseReadiness struct {
State string `json:"state"`
DeliveryState string `json:"deliveryState"`
BlockingReasons []string `json:"blockingReasons"`
Warnings []string `json:"warnings"`
Git struct {
RepoRoot string `json:"repoRoot"`
WorkspacePath string `json:"workspacePath"`
BranchName string `json:"branchName"`
BaseRef string `json:"baseRef"`
HasDirtyTrackedFiles bool `json:"hasDirtyTrackedFiles"`
HasUntrackedFiles bool `json:"hasUntrackedFiles"`
DirtyEntryCount int `json:"dirtyEntryCount"`
UntrackedEntryCount int `json:"untrackedEntryCount"`
AheadCount int `json:"aheadCount"`
BehindCount int `json:"behindCount"`
IsMergedIntoBase bool `json:"isMergedIntoBase"`
CreatedByRuntime bool `json:"createdByRuntime"`
} `json:"git"`
}
// ExecutionWorkspaces lists every task workspace in the company. They are
// filtered per project here rather than by the API, which has no such filter.
func (c *Client) ExecutionWorkspaces() ([]ExecutionWorkspace, error) {
var raw json.RawMessage
if err := c.do("GET", "/api/companies/"+c.Company+"/execution-workspaces", nil, &raw); err != nil {
return nil, err
}
var list []ExecutionWorkspace
if err := json.Unmarshal(raw, &list); err == nil {
return list, nil
}
var wrapper struct {
Workspaces []ExecutionWorkspace `json:"workspaces"`
Data []ExecutionWorkspace `json:"data"`
}
if err := json.Unmarshal(raw, &wrapper); err != nil {
return nil, err
}
if len(wrapper.Workspaces) > 0 {
return wrapper.Workspaces, nil
}
return wrapper.Data, nil
}
// CloseReadinessFor asks Paperclip to scan one execution workspace's git state.
func (c *Client) CloseReadinessFor(id string) (CloseReadiness, error) {
var out CloseReadiness
err := c.do("GET", "/api/execution-workspaces/"+id+"/close-readiness", nil, &out)
return out, err
}

View File

@@ -21,6 +21,8 @@ import (
)
type projectMsg struct {
git gitState
wts []worktreeRow
dash paperclip.Dashboard
project paperclip.Project
cfg map[string]string
@@ -50,6 +52,9 @@ type projectModel struct {
err error
readAt time.Time
git gitState
wts []worktreeRow
tab int
scroll int
regAt int
@@ -60,7 +65,18 @@ type projectModel struct {
width, height int
}
var projectTabs = []string{"overview", "facts", "costs", "settings", "projects"}
// Tab order is also the reading order, and the numbers are named because they
// are used in the key handling: a bare 4 broke once already.
const (
tabOverview = iota
tabGit
tabFacts
tabCosts
tabSettings
tabProjects
)
var projectTabs = []string{"overview", "git", "facts", "costs", "settings", "projects"}
func newProjectModel(c *paperclip.Client, folder string) projectModel {
return projectModel{client: c, folder: folder}
@@ -104,7 +120,10 @@ func (m projectModel) load() tea.Cmd {
costs = append(costs, taskCost{t.Identifier, t.Status, cost.CostCents, cost.RunCount})
}
dash, _ := c.DashboardSummary()
return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs, dash: dash}
git := readGitState(folder)
wts, _ := readWorktrees(c, proj.ID, 8)
return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs,
dash: dash, git: git, wts: wts}
}
}
@@ -184,6 +203,13 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
case "r":
return m, m.load()
case "t":
// lazygit, in the project folder: it is the tool that can stage,
// exclude and commit. Nothing here reimplements it.
if err := openTool(m.folder, "lazygit"); err != nil {
m.err = err
}
return m, nil
case "R":
if err := startRebuild(m.folder); err != nil {
m.err = err
@@ -191,7 +217,7 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, tea.Quit
case "enter":
if m.tab == 4 {
if m.tab == tabProjects {
m.regs = readRegistry()
if len(m.regs) > 0 && m.regAt < len(m.regs) {
m.confirmSwitch = true
@@ -211,13 +237,13 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case "up", "k":
if m.tab == 4 && m.regAt > 0 {
if m.tab == tabProjects && m.regAt > 0 {
m.regAt--
}
m.scroll--
return m, nil
case "down", "j":
if m.tab == 4 && m.regAt < len(m.regs)-1 {
if m.tab == tabProjects && m.regAt < len(m.regs)-1 {
m.regAt++
}
m.scroll++
@@ -234,6 +260,8 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cfg = msg.cfg
m.agents = msg.agents
m.costs = msg.costs
m.git = msg.git
m.wts = msg.wts
m.err = msg.err
m.readAt = time.Now()
return m, nil
@@ -263,7 +291,9 @@ func (m projectModel) View() string {
}
if m.showHelp {
return helpOverlay("the project", [][2]string{
{"[ ] h l", "facts, costs, settings"},
{"( )", "overview, git, facts, costs, settings, projects"},
{"t", "open lazygit in this folder"},
{"[ ] h l", "previous and next tab"},
{"↑ ↓ j k", "scroll"},
{"r", "read again"},
{"R", "rebuild every pane"},
@@ -283,13 +313,15 @@ func (m projectModel) View() string {
var body []string
switch m.tab {
case 0:
case tabOverview:
body = m.overviewLines(m.width - 4)
case 1:
case tabGit:
body = m.gitLines(m.width - 4)
case tabFacts:
body = m.factLines()
case 2:
case tabCosts:
body = m.costLines()
case 4:
case tabProjects:
body = m.projectListLines(m.width - 4)
default:
body = m.settingLines()
@@ -312,6 +344,7 @@ func (m projectModel) View() string {
footer := hintLine([]string{
keyHint("( )", "tab"),
keyHint("t", "lazygit"),
keyHint("↑↓", "scroll"),
keyHint("r", "refresh"),
keyHint("R", "rebuild"),
@@ -574,3 +607,130 @@ func (m projectModel) projectListLines(width int) []string {
out = append(out, dimStyle().Render(" ~/.config/tuios hosts table; otherwise attach to it by hand."))
return out
}
// gitLines is the git tab: the folder read locally, then what Paperclip knows
// about each task's worktree. The two halves answer different questions and are
// not merged into one verdict.
func (m projectModel) gitLines(width int) []string {
var out []string
g := m.git
report := func(label, value string, bad bool) {
line := " " + mutedStyle2().Render(fmt.Sprintf("%-14s", label))
style := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text))
if bad {
style = style.Foreground(lipgloss.Color(active.Warning))
}
out = append(out, line+style.Render(value))
}
// --- the folder ---
out = append(out, mutedStyle2().Render(" THIS FOLDER"))
if !g.IsRepo {
report("repository", "no — this folder is not a git repository", true)
} else {
branch := g.Branch
if branch == "" {
branch = "(no branch yet)"
}
report("repository", "yes · branch "+branch, false)
report("commits", fmt.Sprintf("%d", g.Commits), !g.HasCommit)
if g.Commits == 0 {
out = append(out, " no commit, so nothing to cut a worktree from")
}
remotes := "none — not needed for worktrees"
if len(g.Remotes) > 0 {
remotes = strings.Join(g.Remotes, ", ")
}
report("remote", remotes, false)
report("changed", fmt.Sprintf("%d staged · %d unstaged · %d untracked",
g.Staged, g.Unstaged, g.Untracked), false)
if g.HasIgnore {
shown := strings.Join(g.Ignore, " ")
if len(shown) > width-24 {
shown = shown[:width-25] + "…"
}
if shown == "" {
shown = "(no entries)"
}
report(".gitignore", shown, !g.ignoresPaperclipWorktrees())
} else {
report(".gitignore", "missing — .paperclip/ must be in it", true)
}
}
out = append(out, "")
// --- what Paperclip will do with each task ---
policy := "not set"
if p := m.project.ExecutionWorkspacePolicy; p != nil && p.Enabled {
policy = p.DefaultMode
if p.Strategy.Type != "" {
policy += " · " + p.Strategy.Type
}
if p.Strategy.Base != "" {
policy += " · base " + p.Strategy.Base
}
}
report("worktrees", policy, m.project.ExecutionWorkspacePolicy == nil ||
(m.project.ExecutionWorkspacePolicy != nil && m.project.ExecutionWorkspacePolicy.Strategy.Type != "git_worktree"))
out = append(out, "")
// --- what to do, when something is wrong ---
if !g.ready() || !g.ignoresPaperclipWorktrees() {
out = append(out, mutedStyle2().Render(" WHAT TO DO"))
folder := m.folder
if !g.ignoresPaperclipWorktrees() {
out = append(out, " .paperclip/ is not ignored: a commit would swallow every worktree.")
out = append(out, " "+dimStyle().Render("t lazygit · select .gitignore · e to edit · add .paperclip/"))
out = append(out, "")
}
if !g.IsRepo {
out = append(out, " This folder is not a repository, so no task can have its own branch.")
out = append(out, " "+dimStyle().Render("cd "+folder+" && git init -b main"))
out = append(out, " "+dimStyle().Render("then commit something — in lazygit: t, then c"))
} else if !g.HasCommit {
out = append(out, " This repository has no commit, so there is nothing to cut a worktree from.")
out = append(out, " "+dimStyle().Render("t lazygit · space to stage · c to commit"))
}
out = append(out, "")
out = append(out, " Then run "+keyHint("R", "rebuild")+" so every pane is built against the new state.")
out = append(out, "")
}
// --- Paperclip's own scan of the task worktrees ---
out = append(out, mutedStyle2().Render(" WORKTREES, AS PAPERCLIP SEES THEM"))
if len(m.wts) == 0 {
out = append(out, dimStyle().Render(" none yet — one is made when a task runs"))
}
for _, w := range m.wts {
state := "clean"
if w.Dirty > 0 {
state = fmt.Sprintf("%d changed", w.Dirty)
}
extra := ""
if w.Ahead > 0 {
extra += fmt.Sprintf(" · %d ahead", w.Ahead)
}
if w.Behind > 0 {
extra += fmt.Sprintf(" · %d behind", w.Behind)
}
if w.Merged {
extra += " · merged into base"
}
branch := w.Branch
if branch == "" {
branch = "(no branch)"
}
if len(branch) > width-34 {
branch = branch[:width-35] + "…"
}
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(branch)+
dimStyle().Render(" "+state+extra))
if w.Warning != "" {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(w.Warning))
}
}
out = append(out, "")
out = append(out, dimStyle().Render(" read from git in this folder, and from Paperclip's scan of each worktree"))
return out
}