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

@@ -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
}