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:
@@ -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
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")
|
||||
}
|
||||
11
tui/main.go
11
tui/main.go
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
180
tui/project.go
180
tui/project.go
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user