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