264 lines
7.5 KiB
Go
264 lines
7.5 KiB
Go
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")
|
|
}
|