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

@@ -1286,3 +1286,145 @@ I have changed nothing since the last commit except the code you already have (`
- session: [[packs/paperclip-harness/80-sessions/paperclip-harness-multi-machine-setup-program-delivery-git-w|Paperclip harness multi-machine setup: program delivery, git worktrees vs local_path, env var inheritance …[truncated]…]]
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-cannot-2026-11-12-06-28-28|Rich discovery: cannot 2026-11-12 06:28:28]]
- context: [[packs/paperclip-harness/20-context/home|cannot]]
---
## Update (2026-11-12 09:24:20)
## Rich discovery update (2026-11-12 09:24:20)
Research done, measured against the running instance and the installed build rather than remembered. Here is what Paperclip actually offers for git.
## 1. The CLI has no git commands at all
```
paperclipai project list · get · create · update · delete
paperclipai project-workspace list · create · update · delete · runtime-service · runtime-command
paperclipai workspace list · get · close-readiness · operations · update · runtime-*
paperclipai cost · openapi · …
```
Nothing initialises a repository, nothing reports whether a folder is one. The git-shaped verbs are all about *execution workspaces* — the per-task worktrees.
## 2. The web "git screen" is the execution workspace page
Two things on that page, found in the served bundle:
- a **"Git status"** panel showing `branchName`, and
- a **"Source control"** section with `Branch name` ("Useful for isolated worktrees") and `Base ref` editors.
So the screen you are thinking of is per *task worktree*, not the project folder.
## 3. The API behind it — and this is useful to us
`GET /api/execution-workspaces/{id}/close-readiness` returns a `git` object. Live, for AUD-172 a moment ago:
```json
"git": {
"repoRoot": "/home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-…",
"workspacePath":"/home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-…",
"branchName": "AUD-172-add-a-status-md-with-one-line",
"baseRef": "main",
"hasDirtyTrackedFiles": false,
"hasUntrackedFiles": false,
"dirtyEntryCount": 0,
"untrackedEntryCount": 0,
"aheadCount": 0,
"behindCount": 1,
"isMergedIntoBase": true,
"createdByRuntime": true
},
"deliveryState": "merged_by_ancestry",
"warnings": ["This workspace is 1 commit behind main."]
```
That is Paperclip doing the git reading for you: branch, dirtiness, how far behind, whether it is already merged. Nothing we should reimplement.
## 4. The project workspace side is declaration only
`GET /api/projects/{id}/workspaces` returns configuration, no git state:
```
sourceType local_path | git_repo | remote_managed | non_git_path
cwd · repoUrl · repoRef · defaultRef
setupCommand · cleanupCommand
visibility · runtimeConfig · isPrimary
```
And the policy on the project has more hooks than we use:
```
workspaceStrategy.type project_primary | git_worktree | adapter_managed | cloud_sandbox
baseRef · branchTemplate · existingBranch · worktreeParentDir
provisionCommand · runtimeProvisionCommand · teardownCommand
defaultMode shared_workspace | isolated_workspace | operator_branch | adapter_default
sharedWorkspaceConcurrency auto | serialize | allow
```
## 5. The finding that matters most
**Paperclip does not check that the folder is a repository.** The whole validation is:
```js
function validateProjectWorkspace(value, ctx) {
const sourceType = value.sourceType ?? "local_path";
const hasCwd = …; const hasRepo = …;
if (sourceType === "remote_managed") { …; return; }
if (!hasCwd && !hasRepo) ctx.addIssue({ message: "Workspace requires at least one of cwd or repoUrl." });
}
```
`git_repo` with a folder that is not a repository is accepted and fails later, when a task first tries to make a worktree. Your instinct was right: this has to be caught on our side, at creation, or it is not caught at all.
## 6. What cannot be done through Paperclip
- **No endpoint to create a worktree on demand.** Execution workspaces appear when a task runs. So the first task is the only real test that the repository is usable — we cannot "try one" beforehand.
- **No hook for `git init`.** `provisionCommand` runs per worktree, which is far too late, and `setupCommand` is per project workspace but is not a validation step.
- `POST /api/execution-workspaces/{id}/reconcile-branch` exists for repairing a drifted branch record, and `existingBranch` lets a task be pointed at a branch you made yourself, instead of `{{issue.identifier}}-{{slug}}`.
## 7. One correction to what I built
Our create body sets the policy's `baseRef` but leaves the project workspace's `defaultRef` as `null` — visible on both existing projects. Those are two fields for the same idea and both should be set. Small, but it is the kind of gap that bites later.
Also: `non_git_path` exists as a `sourceType`, but it appears exactly once in the whole build (in the schema) and nothing treats it differently from `local_path`. I would keep using `local_path` and not invent a meaning it does not have.
## What this means for the plan you approved
| Piece | Change from what we discussed |
|---|---|
| Gate at creation | stays ours, and is now clearly the **only** early check. Also set `defaultRef`, and refuse to call a non-repo `git_repo` |
| `git` tab | becomes **two layers**: the folder's own state (we read it), and Paperclip's `close-readiness` scan of each task worktree — branch, dirty, ahead/behind, merged. The second gives "1 commit behind main" and "merged into main" without us computing anything |
| `.gitignore` editing | unchanged: lazygit, opened with `t`, files panel `i`, then `c` to commit |
| Banner | unchanged, one line while the folder cannot hold worktrees |
| Two-step creation | unchanged: repo + `.gitignore` + stage, stop, you commit in lazygit, run again to get the worktree project |
The research also suggests one extra thing worth having in the `git` tab, since the data is free: **the list of this project's worktrees with their git state**, including ones that are behind `main` or already merged. That is the same view Paperclip's web screen gives, in the place you are already working.
I have not touched `~/SECURITY`, and I will not run `office` on it.
Say go and I will build it in this order: the creation gate (with `defaultRef` and the honest `sourceType`), then the `git` tab with both layers, then the banner. All of it lives in the commands and the program, so nothing needs rebuilding on any machine.
## Source paths observed
- `./scripts/provision-worktree-runtime.sh`
- `./scripts/provision-worktree.sh`
- `./scripts/teardown-worktree.sh`
- `path/to/worktree`
- `github.com/acme/growth-playbook`
- `home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-add-a-status-md-with-one-line`
- `home/sam/paseo/projects/test-alpha`
- `home/sam/SECURITY`
- `p/worktrees/AUD-173-add-a-line-to-readme`
- `home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-`
## Evidence
- Confidence: 84%
- Reason: Update component/repository context with detailed discovered structure and configuration
## Related learned memories <!-- same-turn-2026-11-12 09:24:20 -->
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-git-repo-2026-11-12-09-24-20|Rich discovery: git_repo 2026-11-12 09:24:20]]
- context: [[packs/paperclip-harness/20-context/home|git_repo]]
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|git_repo repository structure and configuration patterns]]

View File

@@ -941,3 +941,113 @@ Operational note: home-manager-only changes are safe to `nixos-rebuild switch` (
- runbook: [[packs/paperclip-harness/70-runbooks/tmp-deployment-operations|Rebuild .27 and .51 after adding ~/.local/bin to home-manager PATH]]
- action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Added ~/.local/bin to PATH on .27/.51 and committed; .13 untouched]]
- session: [[packs/paperclip-harness/80-sessions/nixos-fleet-path-fix-rollout-27-and-51-need-home-rebuild-13-|NixOS fleet PATH fix rollout: .27 and .51 need home rebuild, .13 already correct]]
---
## Update (2026-11-12 07:09:34)
## Rich discovery update (2026-11-12 07:09:34)
Your summary of the machinery is exactly right, and nothing you asked was silly. TUIOS on each machine, `office` copied to each, Paperclip and therefore the agents only on `.13`. Everything below follows from that one fact.
## 1. Worktrees by default — agreed, and now it is the default
You are right that worktrees are the normal case, not an optional extra. Without them two tasks edit the same files, and `Work>change`, `Work>review` and `Work>merge` have nothing to work from — they stop being a review gate and become decoration.
I also found a real bug while doing it: the base branch was hardcoded to `main`. A repository on `master` would have been given a worktree policy pointing at a branch that does not exist, so every run would fail on first use. It is now read from the folder.
```
worktrees: yes — a repository on branch 'main', so each task gets its own
worktrees: no — this is not a repository
```
## 2. Asking, not doing it silently
This is the part where I would push back on doing it automatically, and `~/SECURITY` is the perfect example of why.
To give a folder worktrees you have to run `git init`, `git add -A` and a first commit — which commits **every file in that folder**, including anything you forgot was there. In `SECURITY` that means `02-incident-2026-11-11.md`, `03-secret-rotation.md`, `04-login-audit.md`. Committing is a decision about your own files, and it is not one a setup command should make on your behalf. A first commit is also nearly impossible to take back quietly.
So it asks, and says what it will do before doing it:
```
/tmp/officetest is not a git repository, so each task cannot have its own branch and worktree.
without them, two tasks can change the same files at once, and there
is no diff to review and nothing to merge.
make it a repository now? This runs: git init -b main, git add -A,
and one commit of EVERY file in the folder, including anything you
have not noticed. Add a .gitignore first if that is not what you want.
type yes to do it:
```
Three ways through, in the order I would use them:
| Situation | What happens |
|---|---|
| at a terminal | it asks; `yes` makes the repository, anything else leaves it alone |
| in a script | `--git-init` makes it without asking |
| no terminal at all | it changes nothing and prints the exact commands to run yourself |
Say no, and the project is still created — as `local_path`, in place, with no branch and nothing to merge. That is honest for a folder of notes and wrong for code, and it now says which one you got.
Verified on a throwaway folder: with `--git-init` it made one commit on `main` and reported ready; a second call found it already ready and did nothing; a repository with no commit was refused with the right one-line fix.
## 3. Warning when it is not the Paperclip machine — and refusing for a new project
I made it stronger than a warning for `--new`, because the failure is invisible until a task runs:
```
refused: this folder is on sam-4screen-desktop, but Paperclip and its agents run on nixos-desktop
the panes will open here and will read this folder correctly
the agents will not: they start on nixos-desktop, where /home/sam/SECURITY does not exist
a task you assign will fail, or work on a different folder
move the folder to nixos-desktop, mount it there, or pass --anywhere to go ahead anyway
```
My reasoning for refuse rather than warn: creating that project writes a lasting record in Paperclip that can never work, and nothing about the panes will look wrong. Adopting an existing project — `--project` instead of `--new` — only warns, because opening panes to read a folder that lives elsewhere is a legitimate thing to want.
`--anywhere` proceeds anyway.
## What this means for the `security` project
It is currently `local_path` on `.27`, so it has the problem in two ways at once: no worktrees, and its folder is invisible to the agents. A project's policy is fixed when it is created, so it cannot be upgraded — it has to be made again.
You have three choices, and I have not touched it while you decide:
1. **Move the notes to `.13`** and re-create it there. Cleanest: panes, agents and worktrees all on the same machine.
2. **Leave it on `.27`** and use its panes only as a place to read and write the notes yourself, with no agent work.
3. **Mount `.27`'s home on `.13`** so agents can see the path. Then re-create it. Adds a mount that can fail silently.
If you want option 1, the commands are:
```sh
cd ~/SECURITY && git init -b main && git add -A && git commit -m "security notes as they stand"
rsync -a ~/SECURITY/ sam@192.168.20.13:/home/sam/SECURITY/ # from .27
# then on .13:
office ~/SECURITY --new "security"
```
Add a `.gitignore` before the first commit if any of those files should stay out of the history.
## Committed
`paperclip-harness` → `f7b77f3`. `office --help` now documents the worktree rule, the question, and where things run, alongside the per-machine commands.
Nothing needs rebuilding for any of this — it is all in the commands, which `--host` copies to each machine. The only outstanding rebuild is still `.51`, for `~/.local/bin` on `PATH`, and that is optional now that the commands read the secrets file themselves.
## Source paths observed
- `tmp/officetest/.harness/harness.toml`
- `home/sam/SECURITY`
## Evidence
- Confidence: 84%
- Reason: Update component/repository context with detailed discovered structure and configuration
## Related learned memories <!-- same-turn-2026-11-12 07:09:34 -->
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-now-2026-11-12-07-09-34|Rich discovery: now 2026-11-12 07:09:34]]
- context: [[packs/paperclip-harness/20-context/paperclip-harness|now]]
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|now repository structure and configuration patterns]]

View File

@@ -0,0 +1,160 @@
---
type: context
id: context.paperclip-harness.with
title: with
status: active
source_of_truth: false
freshness: current
last_reviewed: 2026-11-12
tags:
- pack/paperclip-harness
- agent-memory/context
- autolearn
- rich-discovery
- repo/with
---
# with
## Rich discovery update (2026-11-12 09:01:33)
## Yes — lazygit handles `.gitignore`, so no rethink needed
I checked the installed build rather than trusting memory. `lazygit 0.66.2`, and its binary contains exactly the code paths and menu items for this:
```
FilesController.ignoreOrExcludeFile / ignoreOrExcludeMenu
"Add to .gitignore" "Add to .git/info/exclude" "Ignore file"
"Cannot ignore .gitignore"
"Can't stage '{{.path}}' because it is a worktree. Remove it with …, or ignore it with …"
"Amend last commit"
```
So from the Files panel:
| key | does |
|---|---|
| `i` | menu: **Add to .gitignore** or **Add to .git/info/exclude** for the selected file or folder |
| `space` | stage / unstage |
| `c` | commit |
| `A` | amend the last commit (safe here: nothing is pushed) |
| `e` | edit the file, `.gitignore` included |
| `?` | every key, in case you want the exact ones for this version |
Three details worth knowing:
- It adds the **path** of what you select, one entry per file. Adding the folder `evidence/` ignores the whole folder. For a pattern like `*secret*` you edit `.gitignore` with `e`.
- It refuses to ignore `.gitignore` itself, so that file always gets committed.
- It already knows how to complain about worktrees, which is the next point.
Our `t` tool chooser already opens lazygit **with the project folder as its working directory**, so it lands in the right repository with no setup.
## The one thing that must not be optional
Paperclip puts each task's worktree **inside the project folder**, at `.paperclip/worktrees/…`. If git ever sees those, the repository becomes a mess of nested checkouts of itself. So `.paperclip/` has to be in `.gitignore` from the first moment, on every path — not as advice, as a rule. lazygit warns about staging a worktree, but the ignore entry should already be there before it can happen.
`.harness/` is the opposite: it holds the project's own `check.sh` and notes, the agent may need it, so it should be committed.
## Revised Option 1 — two choices, no file list
You are right that listing files is useless: you cannot act on the list at that moment.
```
This folder is not a git repository, so each task cannot have its own branch and worktree.
Y make the repository and commit EVERY file in it
Hundreds is fine. Anything matched by .gitignore is left out.
A .gitignore is written first, containing .paperclip/ — that one is not optional.
N no repository: no worktrees, tasks edit this folder directly,
no diff to review and nothing to merge
```
Two answers, both honest, and the sentence about `.gitignore` does the teaching.
## The ordering problem, and why your sequence is the right one
If `office` creates the repository **and commits in the same breath**, you never get the chance to exclude anything first. Fixing it afterwards means `git rm --cached` and `--amend` — doable in lazygit, but rewriting the first commit is exactly the kind of thing that goes wrong quietly.
So the flow you described is the correct one, and I would build it as two visible steps:
```
office ~/SECURITY --new "security"
made a repository on branch main
wrote .gitignore → .paperclip/
staged everything; nothing is committed yet
8 files staged, 1 folder (evidence/)
Run t → lazygit to exclude what you do not want (`i` on a file or folder),
then `c` to commit. When a commit exists, run this command again and it will
create the project with worktrees.
```
and the second run, seeing a commit, creates the project with `isolated_workspace` and the base branch it finds.
That way no tool ever writes history on your behalf, and the exclusion decision happens where you can actually see the files — in lazygit. For anyone who does not want two steps, `--commit-all` does it in one.
## The `git` tab in `Project>project`
It shows facts, read live from the folder and from Paperclip each time the pane draws:
```
repository yes, branch main, 1 commit
remote none (not needed for worktrees)
worktrees isolated per task (Paperclip policy: isolated_workspace, base main)
changed 8 staged · 0 unstaged · 3 untracked · 2 ignored
ignored .paperclip/ evidence/
next t → lazygit: `i` to ignore a file, `c` to commit
```
Two things it deliberately does **not** do:
- **it does not edit `.gitignore` itself.** lazygit already does it correctly, including `.git/info/exclude` and the guard on `.gitignore`. Two implementations of one job drift apart; one is enough.
- **it does not hold a copy of the state.** Same rule as everything else here: read it, do not remember it.
And a line in `Project>overview` pointing at it, so the tab is found rather than discovered.
## The banner
One line, only while something is wrong, in every pane:
```
no worktrees — no commit in this folder · Project>project → git (t: lazygit)
```
It disappears the moment a commit exists, so it cannot become furniture.
## What I would build
| | |
|---|---|
| Gate in `office` — two choices, write `.gitignore`, stage, stop before committing | **yes** |
| `--commit-all` for the one-step case | **yes** |
| `git` tab in `Project>project`: state, policy, counts, one-line next step | **yes** |
| One-line banner in every pane while not worktree-ready | **yes** |
| `.gitignore` editing inside our interface | **no** — lazygit does it, `t` opens it |
| Interesting complication: showing `staged` counts means running `git status` | read on refresh, on a button, not on every keystroke |
I have not touched `~/SECURITY`. It is still not a repository, and I will not run `office` on it — when the gate exists you can run it yourself and answer Y or N with everything in front of you.
## Source paths observed
- `run/current-system/sw/bin/lazygit`
- `b/rebase-merge/doneremote.origin.urlrebase.updateRefsopen`
- `.git/info/excludeRemove`
- `.git/info/exclude`
## Evidence
- Confidence: 84%
- Reason: Update component/repository context with detailed discovered structure and configuration
## Related
- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]]
## Related learned memories <!-- same-turn-2026-11-12 09:01:33 -->
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-with-2026-11-12-09-01-33|Rich discovery: with 2026-11-12 09:01:33]]
- context: [[packs/paperclip-harness/20-context/with|with]]
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|with repository structure and configuration patterns]]

View File

@@ -549,3 +549,34 @@ Follow-up: consider making `office` warn/refuse when folder's machine != Papercl
- session: [[packs/paperclip-harness/80-sessions/paperclip-harness-multi-machine-setup-program-delivery-git-w|Paperclip harness multi-machine setup: program delivery, git worktrees vs local_path, env var inheritance …[truncated]…]]
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-cannot-2026-11-12-06-28-28|Rich discovery: cannot 2026-11-12 06:28:28]]
- context: [[packs/paperclip-harness/20-context/home|cannot]]
---
## Update (2026-11-12 07:23:27)
Proposal put to the user (not yet approved/implemented) for alerting users that git must be set up at project creation and that files may need to be gitignored.
Decision (recommended):
- BUILD a real three-answer gate at creation (A commit everything with file list shown; B write starter .gitignore then commit rest; C no repo / direct-edit mode). Rationale: the commit decision is made once, with the file list in front of the user, because commit contents define what the agent sees.
- BUILD a `git` tab in `Project>project` that reads the folder live (repo/branch/commits/remote/worktrees/ignored + project policy + copy-paste instructions; `t`→lazygit, `g`→starter .gitignore). Rationale: honest, always-current facts about the project; also surfaces policy mismatch like `no isolation (policy: local_path)`.
- BUILD a one-line banner in every pane while the project isn't a git repo: `no worktrees — this folder is not a git repository · Project>project → git`. Catches pre-existing projects like the current `security` one.
- Optionally write a starter `.gitignore` only on demand.
REJECTED / NOT TO BUILD:
- A new ninth pane (8 already; this is a project fact, not a work page).
- Auto-committing anything (what you commit is what the agent sees; must never be a side effect).
- Requiring a remote (worktrees + merges are local; remote is backup only).
Consequences/caveats: "everything" must never be shown without the concrete file list; the git tab must show Paperclip policy alongside folder state; implementation still pending user selection among the four options. Do not run `office` on `~/SECURITY` — user initializes it themselves.
## Evidence
- Confidence: 80%
- Reason: Captures a clear architectural/UX decision with rationale and rejected alternatives, though it is a pending proposal awaiting user confirmation.
## Related learned memories <!-- same-turn-2026-11-12 07:23:27 -->
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|Paperclip repo/git mechanics: worktrees, commits, bin/merge, remote necessity]]
- decision: [[packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li|Decision (proposed, pending user pick): gate git at project creation, add Project>project git tab, add no …[truncated]…]]
- session: [[packs/paperclip-harness/80-sessions/git-init-gating-for-paperclip-projects-design-options-and-re|Git-init gating for Paperclip projects: design options and repo-mechanics findings]]

View File

@@ -6107,3 +6107,426 @@ Three ways to close the gap, and I have already put the least intrusive one in t
- session: [[packs/paperclip-harness/80-sessions/paperclip-harness-multi-machine-setup-program-delivery-git-w|Paperclip harness multi-machine setup: program delivery, git worktrees vs local_path, env var inheritance …[truncated]…]]
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-cannot-2026-11-12-06-28-28|Rich discovery: cannot 2026-11-12 06:28:28]]
- context: [[packs/paperclip-harness/20-context/home|cannot]]
---
## Update (2026-11-12 07:09:34)
## Structural/configuration observations (2026-11-12 07:09:34)
Your summary of the machinery is exactly right, and nothing you asked was silly. TUIOS on each machine, `office` copied to each, Paperclip and therefore the agents only on `.13`. Everything below follows from that one fact.
## 1. Worktrees by default — agreed, and now it is the default
You are right that worktrees are the normal case, not an optional extra. Without them two tasks edit the same files, and `Work>change`, `Work>review` and `Work>merge` have nothing to work from — they stop being a review gate and become decoration.
I also found a real bug while doing it: the base branch was hardcoded to `main`. A repository on `master` would have been given a worktree policy pointing at a branch that does not exist, so every run would fail on first use. It is now read from the folder.
```
worktrees: yes — a repository on branch 'main', so each task gets its own
worktrees: no — this is not a repository
```
## 2. Asking, not doing it silently
This is the part where I would push back on doing it automatically, and `~/SECURITY` is the perfect example of why.
To give a folder worktrees you have to run `git init`, `git add -A` and a first commit — which commits **every file in that folder**, including anything you forgot was there. In `SECURITY` that means `02-incident-2026-11-11.md`, `03-secret-rotation.md`, `04-login-audit.md`. Committing is a decision about your own files, and it is not one a setup command should make on your behalf. A first commit is also nearly impossible to take back quietly.
So it asks, and says what it will do before doing it:
```
/tmp/officetest is not a git repository, so each task cannot have its own branch and worktree.
without them, two tasks can change the same files at once, and there
is no diff to review and nothing to merge.
make it a repository now? This runs: git init -b main, git add -A,
and one commit of EVERY file in the folder, including anything you
have not noticed. Add a .gitignore first if that is not what you want.
type yes to do it:
```
Three ways through, in the order I would use them:
| Situation | What happens |
|---|---|
| at a terminal | it asks; `yes` makes the repository, anything else leaves it alone |
| in a script | `--git-init` makes it without asking |
| no terminal at all | it changes nothing and prints the exact commands to run yourself |
Say no, and the project is still created — as `local_path`, in place, with no branch and nothing to merge. That is honest for a folder of notes and wrong for code, and it now says which one you got.
Verified on a throwaway folder: with `--git-init` it made one commit on `main` and reported ready; a second call found it already ready and did nothing; a repository with no commit was refused with the right one-line fix.
## 3. Warning when it is not the Paperclip machine — and refusing for a new project
I made it stronger than a warning for `--new`, because the failure is invisible until a task runs:
```
refused: this folder is on sam-4screen-desktop, but Paperclip and its agents run on nixos-desktop
the panes will open here and will read this folder correctly
the agents will not: they start on nixos-desktop, where /home/sam/SECURITY does not exist
a task you assign will fail, or work on a different folder
move the folder to nixos-desktop, mount it there, or pass --anywhere to go ahead anyway
```
My reasoning for refuse rather than warn: creating that project writes a lasting record in Paperclip that can never work, and nothing about the panes will look wrong. Adopting an existing project — `--project` instead of `--new` — only warns, because opening panes to read a folder that lives elsewhere is a legitimate thing to want.
`--anywhere` proceeds anyway.
## What this means for the `security` project
It is currently `local_path` on `.27`, so it has the problem in two ways at once: no worktrees, and its folder is invisible to the agents. A project's policy is fixed when it is created, so it cannot be upgraded — it has to be made again.
You have three choices, and I have not touched it while you decide:
1. **Move the notes to `.13`** and re-create it there. Cleanest: panes, agents and worktrees all on the same machine.
2. **Leave it on `.27`** and use its panes only as a place to read and write the notes yourself, with no agent work.
3. **Mount `.27`'s home on `.13`** so agents can see the path. Then re-create it. Adds a mount that can fail silently.
If you want option 1, the commands are:
```sh
cd ~/SECURITY && git init -b main && git add -A && git commit -m "security notes as they stand"
rsync -a ~/SECURITY/ sam@192.168.20.13:/home/sam/SECURITY/ # from .27
# then on .13:
office ~/SECURITY --new "security"
```
Add a `.gitignore` before the first commit if any of those files should stay out of the history.
## Committed
`paperclip-harness` → `f7b77f3`. `office --help` now documents the worktree rule, the question, and where things run, alongside the per-machine commands.
Nothing needs rebuilding for any of this — it is all in the commands, which `--host` copies to each machine. The only outstanding rebuild is still `.51`, for `~/.local/bin` on `PATH`, and that is optional now that the commands read the secrets file themselves.
## Source paths observed
- `tmp/officetest/.harness/harness.toml`
- `home/sam/SECURITY`
## Evidence
- Confidence: 82%
- Reason: Captured factual repo structure/configuration patterns from rich discovery
## Related learned memories <!-- same-turn-2026-11-12 07:09:34 -->
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-now-2026-11-12-07-09-34|Rich discovery: now 2026-11-12 07:09:34]]
- context: [[packs/paperclip-harness/20-context/paperclip-harness|now]]
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|now repository structure and configuration patterns]]
---
## Update (2026-11-12 07:23:27)
Verified/established facts about how Paperclip uses git (relevant when advising on project setup):
- `bin/merge` is LOCAL only — it rebases and merges; there is no `push` command in it (checked by search). So merging a task branch does not publish anywhere.
- `git worktree add` needs an existing commit to cut the branch from. A repo with no commit → Paperclip's worktree step fails.
- A worktree is a checkout of a commit: uncommitted/unignored-vs-ignored files do NOT appear in the worktree. Therefore an empty initial commit gives the agent an empty working view. Rule of thumb: what you commit = what the agent sees; ignored files are invisible to it.
- Paperclip records only a `cwd` for a project; it does not require a remote and does not itself push. A remote (e.g. Gitea) is optional, for backup/cross-machine copies; cloning/moving to another machine otherwise needs push or rsync.
- Project isolation is a project policy (e.g. `local_path` = no worktree isolation) surfaced in `Project>project`; mismatch between the folder's git state and the policy is currently invisible from the panes.
- lazygit is already available in a tool pane via hotkey `t`.
Safe source paths referenced: `bin/merge`, `Project>project` tabs (`overview`, `facts`, `costs`, `settings`, `projects`).
## Evidence
- Confidence: 88%
- Reason: Factual repository/mechanics discoveries with safe paths that will guide future git-related work.
## Related learned memories <!-- same-turn-2026-11-12 07:23:27 -->
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|Paperclip repo/git mechanics: worktrees, commits, bin/merge, remote necessity]]
- decision: [[packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li|Decision (proposed, pending user pick): gate git at project creation, add Project>project git tab, add no …[truncated]…]]
- session: [[packs/paperclip-harness/80-sessions/git-init-gating-for-paperclip-projects-design-options-and-re|Git-init gating for Paperclip projects: design options and repo-mechanics findings]]
---
## Update (2026-11-12 09:01:33)
## Structural/configuration observations (2026-11-12 09:01:33)
## Yes — lazygit handles `.gitignore`, so no rethink needed
I checked the installed build rather than trusting memory. `lazygit 0.66.2`, and its binary contains exactly the code paths and menu items for this:
```
FilesController.ignoreOrExcludeFile / ignoreOrExcludeMenu
"Add to .gitignore" "Add to .git/info/exclude" "Ignore file"
"Cannot ignore .gitignore"
"Can't stage '{{.path}}' because it is a worktree. Remove it with …, or ignore it with …"
"Amend last commit"
```
So from the Files panel:
| key | does |
|---|---|
| `i` | menu: **Add to .gitignore** or **Add to .git/info/exclude** for the selected file or folder |
| `space` | stage / unstage |
| `c` | commit |
| `A` | amend the last commit (safe here: nothing is pushed) |
| `e` | edit the file, `.gitignore` included |
| `?` | every key, in case you want the exact ones for this version |
Three details worth knowing:
- It adds the **path** of what you select, one entry per file. Adding the folder `evidence/` ignores the whole folder. For a pattern like `*secret*` you edit `.gitignore` with `e`.
- It refuses to ignore `.gitignore` itself, so that file always gets committed.
- It already knows how to complain about worktrees, which is the next point.
Our `t` tool chooser already opens lazygit **with the project folder as its working directory**, so it lands in the right repository with no setup.
## The one thing that must not be optional
Paperclip puts each task's worktree **inside the project folder**, at `.paperclip/worktrees/…`. If git ever sees those, the repository becomes a mess of nested checkouts of itself. So `.paperclip/` has to be in `.gitignore` from the first moment, on every path — not as advice, as a rule. lazygit warns about staging a worktree, but the ignore entry should already be there before it can happen.
`.harness/` is the opposite: it holds the project's own `check.sh` and notes, the agent may need it, so it should be committed.
## Revised Option 1 — two choices, no file list
You are right that listing files is useless: you cannot act on the list at that moment.
```
This folder is not a git repository, so each task cannot have its own branch and worktree.
Y make the repository and commit EVERY file in it
Hundreds is fine. Anything matched by .gitignore is left out.
A .gitignore is written first, containing .paperclip/ — that one is not optional.
N no repository: no worktrees, tasks edit this folder directly,
no diff to review and nothing to merge
```
Two answers, both honest, and the sentence about `.gitignore` does the teaching.
## The ordering problem, and why your sequence is the right one
If `office` creates the repository **and commits in the same breath**, you never get the chance to exclude anything first. Fixing it afterwards means `git rm --cached` and `--amend` — doable in lazygit, but rewriting the first commit is exactly the kind of thing that goes wrong quietly.
So the flow you described is the correct one, and I would build it as two visible steps:
```
office ~/SECURITY --new "security"
made a repository on branch main
wrote .gitignore → .paperclip/
staged everything; nothing is committed yet
8 files staged, 1 folder (evidence/)
Run t → lazygit to exclude what you do not want (`i` on a file or folder),
then `c` to commit. When a commit exists, run this command again and it will
create the project with worktrees.
```
and the second run, seeing a commit, creates the project with `isolated_workspace` and the base branch it finds.
That way no tool ever writes history on your behalf, and the exclusion decision happens where you can actually see the files — in lazygit. For anyone who does not want two steps, `--commit-all` does it in one.
## The `git` tab in `Project>project`
It shows facts, read live from the folder and from Paperclip each time the pane draws:
```
repository yes, branch main, 1 commit
remote none (not needed for worktrees)
worktrees isolated per task (Paperclip policy: isolated_workspace, base main)
changed 8 staged · 0 unstaged · 3 untracked · 2 ignored
ignored .paperclip/ evidence/
next t → lazygit: `i` to ignore a file, `c` to commit
```
Two things it deliberately does **not** do:
- **it does not edit `.gitignore` itself.** lazygit already does it correctly, including `.git/info/exclude` and the guard on `.gitignore`. Two implementations of one job drift apart; one is enough.
- **it does not hold a copy of the state.** Same rule as everything else here: read it, do not remember it.
And a line in `Project>overview` pointing at it, so the tab is found rather than discovered.
## The banner
One line, only while something is wrong, in every pane:
```
no worktrees — no commit in this folder · Project>project → git (t: lazygit)
```
It disappears the moment a commit exists, so it cannot become furniture.
## What I would build
| | |
|---|---|
| Gate in `office` — two choices, write `.gitignore`, stage, stop before committing | **yes** |
| `--commit-all` for the one-step case | **yes** |
| `git` tab in `Project>project`: state, policy, counts, one-line next step | **yes** |
| One-line banner in every pane while not worktree-ready | **yes** |
| `.gitignore` editing inside our interface | **no** — lazygit does it, `t` opens it |
| Interesting complication: showing `staged` counts means running `git status` | read on refresh, on a button, not on every keystroke |
I have not touched `~/SECURITY`. It is still not a repository, and I will not run `office` on it — when the gate exists you can run it yourself and answer Y or N with everything in front of you.
## Source paths observed
- `run/current-system/sw/bin/lazygit`
- `b/rebase-merge/doneremote.origin.urlrebase.updateRefsopen`
- `.git/info/excludeRemove`
- `.git/info/exclude`
## Evidence
- Confidence: 82%
- Reason: Captured factual repo structure/configuration patterns from rich discovery
## Related learned memories <!-- same-turn-2026-11-12 09:01:33 -->
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-with-2026-11-12-09-01-33|Rich discovery: with 2026-11-12 09:01:33]]
- context: [[packs/paperclip-harness/20-context/with|with]]
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|with repository structure and configuration patterns]]
---
## Update (2026-11-12 09:24:20)
## Structural/configuration observations (2026-11-12 09:24:20)
Research done, measured against the running instance and the installed build rather than remembered. Here is what Paperclip actually offers for git.
## 1. The CLI has no git commands at all
```
paperclipai project list · get · create · update · delete
paperclipai project-workspace list · create · update · delete · runtime-service · runtime-command
paperclipai workspace list · get · close-readiness · operations · update · runtime-*
paperclipai cost · openapi · …
```
Nothing initialises a repository, nothing reports whether a folder is one. The git-shaped verbs are all about *execution workspaces* — the per-task worktrees.
## 2. The web "git screen" is the execution workspace page
Two things on that page, found in the served bundle:
- a **"Git status"** panel showing `branchName`, and
- a **"Source control"** section with `Branch name` ("Useful for isolated worktrees") and `Base ref` editors.
So the screen you are thinking of is per *task worktree*, not the project folder.
## 3. The API behind it — and this is useful to us
`GET /api/execution-workspaces/{id}/close-readiness` returns a `git` object. Live, for AUD-172 a moment ago:
```json
"git": {
"repoRoot": "/home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-…",
"workspacePath":"/home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-…",
"branchName": "AUD-172-add-a-status-md-with-one-line",
"baseRef": "main",
"hasDirtyTrackedFiles": false,
"hasUntrackedFiles": false,
"dirtyEntryCount": 0,
"untrackedEntryCount": 0,
"aheadCount": 0,
"behindCount": 1,
"isMergedIntoBase": true,
"createdByRuntime": true
},
"deliveryState": "merged_by_ancestry",
"warnings": ["This workspace is 1 commit behind main."]
```
That is Paperclip doing the git reading for you: branch, dirtiness, how far behind, whether it is already merged. Nothing we should reimplement.
## 4. The project workspace side is declaration only
`GET /api/projects/{id}/workspaces` returns configuration, no git state:
```
sourceType local_path | git_repo | remote_managed | non_git_path
cwd · repoUrl · repoRef · defaultRef
setupCommand · cleanupCommand
visibility · runtimeConfig · isPrimary
```
And the policy on the project has more hooks than we use:
```
workspaceStrategy.type project_primary | git_worktree | adapter_managed | cloud_sandbox
baseRef · branchTemplate · existingBranch · worktreeParentDir
provisionCommand · runtimeProvisionCommand · teardownCommand
defaultMode shared_workspace | isolated_workspace | operator_branch | adapter_default
sharedWorkspaceConcurrency auto | serialize | allow
```
## 5. The finding that matters most
**Paperclip does not check that the folder is a repository.** The whole validation is:
```js
function validateProjectWorkspace(value, ctx) {
const sourceType = value.sourceType ?? "local_path";
const hasCwd = …; const hasRepo = …;
if (sourceType === "remote_managed") { …; return; }
if (!hasCwd && !hasRepo) ctx.addIssue({ message: "Workspace requires at least one of cwd or repoUrl." });
}
```
`git_repo` with a folder that is not a repository is accepted and fails later, when a task first tries to make a worktree. Your instinct was right: this has to be caught on our side, at creation, or it is not caught at all.
## 6. What cannot be done through Paperclip
- **No endpoint to create a worktree on demand.** Execution workspaces appear when a task runs. So the first task is the only real test that the repository is usable — we cannot "try one" beforehand.
- **No hook for `git init`.** `provisionCommand` runs per worktree, which is far too late, and `setupCommand` is per project workspace but is not a validation step.
- `POST /api/execution-workspaces/{id}/reconcile-branch` exists for repairing a drifted branch record, and `existingBranch` lets a task be pointed at a branch you made yourself, instead of `{{issue.identifier}}-{{slug}}`.
## 7. One correction to what I built
Our create body sets the policy's `baseRef` but leaves the project workspace's `defaultRef` as `null` — visible on both existing projects. Those are two fields for the same idea and both should be set. Small, but it is the kind of gap that bites later.
Also: `non_git_path` exists as a `sourceType`, but it appears exactly once in the whole build (in the schema) and nothing treats it differently from `local_path`. I would keep using `local_path` and not invent a meaning it does not have.
## What this means for the plan you approved
| Piece | Change from what we discussed |
|---|---|
| Gate at creation | stays ours, and is now clearly the **only** early check. Also set `defaultRef`, and refuse to call a non-repo `git_repo` |
| `git` tab | becomes **two layers**: the folder's own state (we read it), and Paperclip's `close-readiness` scan of each task worktree — branch, dirty, ahead/behind, merged. The second gives "1 commit behind main" and "merged into main" without us computing anything |
| `.gitignore` editing | unchanged: lazygit, opened with `t`, files panel `i`, then `c` to commit |
| Banner | unchanged, one line while the folder cannot hold worktrees |
| Two-step creation | unchanged: repo + `.gitignore` + stage, stop, you commit in lazygit, run again to get the worktree project |
The research also suggests one extra thing worth having in the `git` tab, since the data is free: **the list of this project's worktrees with their git state**, including ones that are behind `main` or already merged. That is the same view Paperclip's web screen gives, in the place you are already working.
I have not touched `~/SECURITY`, and I will not run `office` on it.
Say go and I will build it in this order: the creation gate (with `defaultRef` and the honest `sourceType`), then the `git` tab with both layers, then the banner. All of it lives in the commands and the program, so nothing needs rebuilding on any machine.
## Source paths observed
- `./scripts/provision-worktree-runtime.sh`
- `./scripts/provision-worktree.sh`
- `./scripts/teardown-worktree.sh`
- `path/to/worktree`
- `github.com/acme/growth-playbook`
- `home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-add-a-status-md-with-one-line`
- `home/sam/paseo/projects/test-alpha`
- `home/sam/SECURITY`
- `p/worktrees/AUD-173-add-a-line-to-readme`
- `home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-`
## Evidence
- Confidence: 82%
- Reason: Captured factual repo structure/configuration patterns from rich discovery
## Related learned memories <!-- same-turn-2026-11-12 09:24:20 -->
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-git-repo-2026-11-12-09-24-20|Rich discovery: git_repo 2026-11-12 09:24:20]]
- context: [[packs/paperclip-harness/20-context/home|git_repo]]
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|git_repo repository structure and configuration patterns]]

View File

@@ -0,0 +1,57 @@
---
type: session
id: session.paperclip-harness.git-init-gating-for-paperclip-projects-design-options-and-re
title: Git-init gating for Paperclip projects: design options and repo-mechanics findings
status: active
source_of_truth: false
freshness: current
last_reviewed: 2026-11-12
tags:
- pack/paperclip-harness
- agent-memory/session
- paperclip-harness
- git
- worktree
- project-creation
- gitignore
- security-project
- design-proposal
---
# Git-init gating for Paperclip projects: design options and repo-mechanics findings
Turn was a design/discovery discussion about how Paperclip should enforce that a project folder is a git repo at creation time. No code was written and no changes were made; the target folder `~/SECURITY` was explicitly protected by the user ("Do NOT START the SECURITY PROJECT — I want to initialize everything myself").
Key durable findings and mechanics established:
1. `~/SECURITY` (reported to be on host `.13`) is NOT yet a git repository (confirmed: `fatal: not a git repository`). Its top-level contents (names only, contents not read): `01-caddy-exposure-inventory.csv`, `02-incident-2026-11-11.md`, `03-secret-rotation.md`, `04-login-audit.md`, `05-vulnerability-audit-php2shell-http2shell.md`, `06-independent-review.md`, `AGENTS.md`, and directories `evidence/`, `.harness/`, `.pi/` (10 entries total).
2. Why the initial commit matters: (a) `git worktree add` requires a commit to branch from, so no commit means Paperclip's worktree step fails; (b) more importantly a worktree is a checkout of a commit, so anything not committed is invisible to the agent in the worktree — an `--allow-empty` first commit yields an empty agent view. Governing principle stated: **what you commit is what the agent works on; what you ignore is invisible to it.**
3. Remote is optional: `git worktree` is local-only, Paperclip only records a `cwd` per project, and `bin/merge` does not push (verified — no `push` in it; merge rebases/merges locally). A remote (e.g. Gitea) is backup/multi-machine copy, not required for worktrees.
4. Four proposed options for the creation-time alert, with recommendation to build Options 1–3 and skip a new pane / auto-commit / mandatory remote:
- Option 1 (BUILD): replace the yes/no git question at creation with three explicit answers (A commit everything incl. listed files; B write a starter `.gitignore` then commit the rest; C no repo — tasks edit the folder directly). Must show the actual file list, not the word "everything".
- Option 2 (BUILD): add a `git` tab to `Project>project` (alongside existing `overview`, `facts`, `costs`, `settings`, `projects`) that reads the folder live and shows repo state, branch, commits, remote, worktree/isolation status vs project policy (e.g. `no isolation (policy: local_path)`), ignored count, and copy-pasteable setup instructions; hotkeys `t` (lazygit, already wired as a tool pane) and `g` (write starter `.gitignore`).
- Option 3 (BUILD): one-line banner in every pane while the project isn't a git repo: `no worktrees — this folder is not a git repository · Project>project → git`.
- Option 4 (partial): starter `.gitignore` written only on demand, plus lazygit.
- REJECT: a ninth pane (8 is already many; this is a project fact not a work page); auto-committing anything; requiring a remote.
5. Proposed starter `.gitignore` for the security folder (ignore) `.paperclip/`, `.pi/`, `.env`, `.env.*`, `*.pem`, `*.key`, `*secret*`, `*credential*`, `evidence/`, `.DS_Store`, `__pycache__/`, `*.pyc`; intentionally COMMIT `.harness/` (holds project's own `check.sh` and notes the agent may need). Rationale: `03-secret-rotation.md` and `evidence/` are the two things not to commit by reflex.
Status: no implementation; awaiting user to choose which of the four options to build. Do not run `office` on `~/SECURITY`.
## Evidence
- Confidence: 90%
- Reason: Rich sanitized discovery + design proposal with concrete mechanics, paths, rejected alternatives, and explicit user guardrails; useful for a future agent continuing the work.
## Related
- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]]
## Related learned memories <!-- same-turn-2026-11-12 07:23:27 -->
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|Paperclip repo/git mechanics: worktrees, commits, bin/merge, remote necessity]]
- decision: [[packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li|Decision (proposed, pending user pick): gate git at project creation, add Project>project git tab, add no …[truncated]…]]
- session: [[packs/paperclip-harness/80-sessions/git-init-gating-for-paperclip-projects-design-options-and-re|Git-init gating for Paperclip projects: design options and repo-mechanics findings]]

View File

@@ -0,0 +1,161 @@
---
type: session
id: session.paperclip-harness.rich-discovery-git-repo-2026-11-12-09-24-20
title: Rich discovery: git_repo 2026-11-12 09:24:20
status: active
source_of_truth: false
freshness: current
last_reviewed: 2026-11-12
tags:
- pack/paperclip-harness
- agent-memory/session
- autolearn
- rich-discovery
- repo/git-repo
---
# Rich discovery: git_repo 2026-11-12 09:24:20
Sanitized rich discovery snapshot captured automatically from the completed turn.
## Source paths observed
- `./scripts/provision-worktree-runtime.sh`
- `./scripts/provision-worktree.sh`
- `./scripts/teardown-worktree.sh`
- `path/to/worktree`
- `github.com/acme/growth-playbook`
- `home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-add-a-status-md-with-one-line`
- `home/sam/paseo/projects/test-alpha`
- `home/sam/SECURITY`
- `p/worktrees/AUD-173-add-a-line-to-readme`
- `home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-`
## Final answer snapshot
Research done, measured against the running instance and the installed build rather than remembered. Here is what Paperclip actually offers for git.
## 1. The CLI has no git commands at all
```
paperclipai project list · get · create · update · delete
paperclipai project-workspace list · create · update · delete · runtime-service · runtime-command
paperclipai workspace list · get · close-readiness · operations · update · runtime-*
paperclipai cost · openapi · …
```
Nothing initialises a repository, nothing reports whether a folder is one. The git-shaped verbs are all about *execution workspaces* — the per-task worktrees.
## 2. The web "git screen" is the execution workspace page
Two things on that page, found in the served bundle:
- a **"Git status"** panel showing `branchName`, and
- a **"Source control"** section with `Branch name` ("Useful for isolated worktrees") and `Base ref` editors.
So the screen you are thinking of is per *task worktree*, not the project folder.
## 3. The API behind it — and this is useful to us
`GET /api/execution-workspaces/{id}/close-readiness` returns a `git` object. Live, for AUD-172 a moment ago:
```json
"git": {
"repoRoot": "/home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-…",
"workspacePath":"/home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-…",
"branchName": "AUD-172-add-a-status-md-with-one-line",
"baseRef": "main",
"hasDirtyTrackedFiles": false,
"hasUntrackedFiles": false,
"dirtyEntryCount": 0,
"untrackedEntryCount": 0,
"aheadCount": 0,
"behindCount": 1,
"isMergedIntoBase": true,
"createdByRuntime": true
},
"deliveryState": "merged_by_ancestry",
"warnings": ["This workspace is 1 commit behind main."]
```
That is Paperclip doing the git reading for you: branch, dirtiness, how far behind, whether it is already merged. Nothing we should reimplement.
## 4. The project workspace side is declaration only
`GET /api/projects/{id}/workspaces` returns configuration, no git state:
```
sourceType local_path | git_repo | remote_managed | non_git_path
cwd · repoUrl · repoRef · defaultRef
setupCommand · cleanupCommand
visibility · runtimeConfig · isPrimary
```
And the policy on the project has more hooks than we use:
```
workspaceStrategy.type project_primary | git_worktree | adapter_managed | cloud_sandbox
baseRef · branchTemplate · existingBranch · worktreeParentDir
provisionCommand · runtimeProvisionCommand · teardownCommand
defaultMode shared_workspace | isolated_workspace | operator_branch | adapter_default
sharedWorkspaceConcurrency auto | serialize | allow
```
## 5. The finding that matters most
**Paperclip does not check that the folder is a repository.** The whole validation is:
```js
function validateProjectWorkspace(value, ctx) {
const sourceType = value.sourceType ?? "local_path";
const hasCwd = …; const hasRepo = …;
if (sourceType === "remote_managed") { …; return; }
if (!hasCwd && !hasRepo) ctx.addIssue({ message: "Workspace requires at least one of cwd or repoUrl." });
}
```
`git_repo` with a folder that is not a repository is accepted and fails later, when a task first tries to make a worktree. Your instinct was right: this has to be caught on our side, at creation, or it is not caught at all.
## 6. What cannot be done through Paperclip
- **No endpoint to create a worktree on demand.** Execution workspaces appear when a task runs. So the first task is the only real test that the repository is usable — we cannot "try one" beforehand.
- **No hook for `git init`.** `provisionCommand` runs per worktree, which is far too late, and `setupCommand` is per project workspace but is not a validation step.
- `POST /api/execution-workspaces/{id}/reconcile-branch` exists for repairing a drifted branch record, and `existingBranch` lets a task be pointed at a branch you made yourself, instead of `{{issue.identifier}}-{{slug}}`.
## 7. One correction to what I built
Our create body sets the policy's `baseRef` but leaves the project workspace's `defaultRef` as `null` — visible on both existing projects. Those are two fields for the same idea and both should be set. Small, but it is the kind of gap that bites later.
Also: `non_git_path` exists as a `sourceType`, but it appears exactly once in the whole build (in the schema) and nothing treats it differently from `local_path`. I would keep using `local_path` and not invent a meaning it does not have.
## What this means for the plan you approved
| Piece | Change from what we discussed |
|---|---|
| Gate at creation | stays ours, and is now clearly the **only** early check. Also set `defaultRef`, and refuse to call a non-repo `git_repo` |
| `git` tab | becomes **two layers**: the folder's own state (we read it), and Paperclip's `close-readiness` scan of each task worktree — branch, dirty, ahead/behind, merged. The second gives "1 commit behind main" and "merged into main" without us computing anything |
| `.gitignore` editing | unchanged: lazygit, opened with `t`, files panel `i`, then `c` to commit |
| Banner | unchanged, one line while the folder cannot hold worktrees |
| Two-step creation | unchanged: repo + `.gitignore` + stage, stop, you commit in lazygit, run again to get the worktree project |
The research also suggests one extra thing worth having in the `git` tab, since the data is free: **the list of this project's worktrees with their git state**, including ones that are behind `main` or already merged. That is the same view Paperclip's web screen gives, in the place you are already working.
I have not touched `~/SECURITY`, and I will not run `office` on it.
Say go and I will build it in this order: the creation gate (with `defaultRef` and the honest `sourceType`), then the `git` tab with both layers, then the banner. All of it lives in the commands and the program, so nothing needs rebuilding on any machine.
## Evidence
- Confidence: 86%
- Reason: Preserve full sanitized discovery detail from a rich investigation turn
## Related
- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]]
## Related learned memories <!-- same-turn-2026-11-12 09:24:20 -->
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-git-repo-2026-11-12-09-24-20|Rich discovery: git_repo 2026-11-12 09:24:20]]
- context: [[packs/paperclip-harness/20-context/home|git_repo]]
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|git_repo repository structure and configuration patterns]]

View File

@@ -0,0 +1,129 @@
---
type: session
id: session.paperclip-harness.rich-discovery-now-2026-11-12-07-09-34
title: Rich discovery: now 2026-11-12 07:09:34
status: active
source_of_truth: false
freshness: current
last_reviewed: 2026-11-12
tags:
- pack/paperclip-harness
- agent-memory/session
- autolearn
- rich-discovery
- repo/now
---
# Rich discovery: now 2026-11-12 07:09:34
Sanitized rich discovery snapshot captured automatically from the completed turn.
## Source paths observed
- `tmp/officetest/.harness/harness.toml`
- `home/sam/SECURITY`
## Final answer snapshot
Your summary of the machinery is exactly right, and nothing you asked was silly. TUIOS on each machine, `office` copied to each, Paperclip and therefore the agents only on `.13`. Everything below follows from that one fact.
## 1. Worktrees by default — agreed, and now it is the default
You are right that worktrees are the normal case, not an optional extra. Without them two tasks edit the same files, and `Work>change`, `Work>review` and `Work>merge` have nothing to work from — they stop being a review gate and become decoration.
I also found a real bug while doing it: the base branch was hardcoded to `main`. A repository on `master` would have been given a worktree policy pointing at a branch that does not exist, so every run would fail on first use. It is now read from the folder.
```
worktrees: yes — a repository on branch 'main', so each task gets its own
worktrees: no — this is not a repository
```
## 2. Asking, not doing it silently
This is the part where I would push back on doing it automatically, and `~/SECURITY` is the perfect example of why.
To give a folder worktrees you have to run `git init`, `git add -A` and a first commit — which commits **every file in that folder**, including anything you forgot was there. In `SECURITY` that means `02-incident-2026-11-11.md`, `03-secret-rotation.md`, `04-login-audit.md`. Committing is a decision about your own files, and it is not one a setup command should make on your behalf. A first commit is also nearly impossible to take back quietly.
So it asks, and says what it will do before doing it:
```
/tmp/officetest is not a git repository, so each task cannot have its own branch and worktree.
without them, two tasks can change the same files at once, and there
is no diff to review and nothing to merge.
make it a repository now? This runs: git init -b main, git add -A,
and one commit of EVERY file in the folder, including anything you
have not noticed. Add a .gitignore first if that is not what you want.
type yes to do it:
```
Three ways through, in the order I would use them:
| Situation | What happens |
|---|---|
| at a terminal | it asks; `yes` makes the repository, anything else leaves it alone |
| in a script | `--git-init` makes it without asking |
| no terminal at all | it changes nothing and prints the exact commands to run yourself |
Say no, and the project is still created — as `local_path`, in place, with no branch and nothing to merge. That is honest for a folder of notes and wrong for code, and it now says which one you got.
Verified on a throwaway folder: with `--git-init` it made one commit on `main` and reported ready; a second call found it already ready and did nothing; a repository with no commit was refused with the right one-line fix.
## 3. Warning when it is not the Paperclip machine — and refusing for a new project
I made it stronger than a warning for `--new`, because the failure is invisible until a task runs:
```
refused: this folder is on sam-4screen-desktop, but Paperclip and its agents run on nixos-desktop
the panes will open here and will read this folder correctly
the agents will not: they start on nixos-desktop, where /home/sam/SECURITY does not exist
a task you assign will fail, or work on a different folder
move the folder to nixos-desktop, mount it there, or pass --anywhere to go ahead anyway
```
My reasoning for refuse rather than warn: creating that project writes a lasting record in Paperclip that can never work, and nothing about the panes will look wrong. Adopting an existing project — `--project` instead of `--new` — only warns, because opening panes to read a folder that lives elsewhere is a legitimate thing to want.
`--anywhere` proceeds anyway.
## What this means for the `security` project
It is currently `local_path` on `.27`, so it has the problem in two ways at once: no worktrees, and its folder is invisible to the agents. A project's policy is fixed when it is created, so it cannot be upgraded — it has to be made again.
You have three choices, and I have not touched it while you decide:
1. **Move the notes to `.13`** and re-create it there. Cleanest: panes, agents and worktrees all on the same machine.
2. **Leave it on `.27`** and use its panes only as a place to read and write the notes yourself, with no agent work.
3. **Mount `.27`'s home on `.13`** so agents can see the path. Then re-create it. Adds a mount that can fail silently.
If you want option 1, the commands are:
```sh
cd ~/SECURITY && git init -b main && git add -A && git commit -m "security notes as they stand"
rsync -a ~/SECURITY/ sam@192.168.20.13:/home/sam/SECURITY/ # from .27
# then on .13:
office ~/SECURITY --new "security"
```
Add a `.gitignore` before the first commit if any of those files should stay out of the history.
## Committed
`paperclip-harness` → `f7b77f3`. `office --help` now documents the worktree rule, the question, and where things run, alongside the per-machine commands.
Nothing needs rebuilding for any of this — it is all in the commands, which `--host` copies to each machine. The only outstanding rebuild is still `.51`, for `~/.local/bin` on `PATH`, and that is optional now that the commands read the secrets file themselves.
## Evidence
- Confidence: 86%
- Reason: Preserve full sanitized discovery detail from a rich investigation turn
## Related
- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]]
## Related learned memories <!-- same-turn-2026-11-12 07:09:34 -->
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-now-2026-11-12-07-09-34|Rich discovery: now 2026-11-12 07:09:34]]
- context: [[packs/paperclip-harness/20-context/paperclip-harness|now]]
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|now repository structure and configuration patterns]]

View File

@@ -0,0 +1,163 @@
---
type: session
id: session.paperclip-harness.rich-discovery-with-2026-11-12-09-01-33
title: Rich discovery: with 2026-11-12 09:01:33
status: active
source_of_truth: false
freshness: current
last_reviewed: 2026-11-12
tags:
- pack/paperclip-harness
- agent-memory/session
- autolearn
- rich-discovery
- repo/with
---
# Rich discovery: with 2026-11-12 09:01:33
Sanitized rich discovery snapshot captured automatically from the completed turn.
## Source paths observed
- `run/current-system/sw/bin/lazygit`
- `b/rebase-merge/doneremote.origin.urlrebase.updateRefsopen`
- `.git/info/excludeRemove`
- `.git/info/exclude`
## Final answer snapshot
## Yes — lazygit handles `.gitignore`, so no rethink needed
I checked the installed build rather than trusting memory. `lazygit 0.66.2`, and its binary contains exactly the code paths and menu items for this:
```
FilesController.ignoreOrExcludeFile / ignoreOrExcludeMenu
"Add to .gitignore" "Add to .git/info/exclude" "Ignore file"
"Cannot ignore .gitignore"
"Can't stage '{{.path}}' because it is a worktree. Remove it with …, or ignore it with …"
"Amend last commit"
```
So from the Files panel:
| key | does |
|---|---|
| `i` | menu: **Add to .gitignore** or **Add to .git/info/exclude** for the selected file or folder |
| `space` | stage / unstage |
| `c` | commit |
| `A` | amend the last commit (safe here: nothing is pushed) |
| `e` | edit the file, `.gitignore` included |
| `?` | every key, in case you want the exact ones for this version |
Three details worth knowing:
- It adds the **path** of what you select, one entry per file. Adding the folder `evidence/` ignores the whole folder. For a pattern like `*secret*` you edit `.gitignore` with `e`.
- It refuses to ignore `.gitignore` itself, so that file always gets committed.
- It already knows how to complain about worktrees, which is the next point.
Our `t` tool chooser already opens lazygit **with the project folder as its working directory**, so it lands in the right repository with no setup.
## The one thing that must not be optional
Paperclip puts each task's worktree **inside the project folder**, at `.paperclip/worktrees/…`. If git ever sees those, the repository becomes a mess of nested checkouts of itself. So `.paperclip/` has to be in `.gitignore` from the first moment, on every path — not as advice, as a rule. lazygit warns about staging a worktree, but the ignore entry should already be there before it can happen.
`.harness/` is the opposite: it holds the project's own `check.sh` and notes, the agent may need it, so it should be committed.
## Revised Option 1 — two choices, no file list
You are right that listing files is useless: you cannot act on the list at that moment.
```
This folder is not a git repository, so each task cannot have its own branch and worktree.
Y make the repository and commit EVERY file in it
Hundreds is fine. Anything matched by .gitignore is left out.
A .gitignore is written first, containing .paperclip/ — that one is not optional.
N no repository: no worktrees, tasks edit this folder directly,
no diff to review and nothing to merge
```
Two answers, both honest, and the sentence about `.gitignore` does the teaching.
## The ordering problem, and why your sequence is the right one
If `office` creates the repository **and commits in the same breath**, you never get the chance to exclude anything first. Fixing it afterwards means `git rm --cached` and `--amend` — doable in lazygit, but rewriting the first commit is exactly the kind of thing that goes wrong quietly.
So the flow you described is the correct one, and I would build it as two visible steps:
```
office ~/SECURITY --new "security"
made a repository on branch main
wrote .gitignore → .paperclip/
staged everything; nothing is committed yet
8 files staged, 1 folder (evidence/)
Run t → lazygit to exclude what you do not want (`i` on a file or folder),
then `c` to commit. When a commit exists, run this command again and it will
create the project with worktrees.
```
and the second run, seeing a commit, creates the project with `isolated_workspace` and the base branch it finds.
That way no tool ever writes history on your behalf, and the exclusion decision happens where you can actually see the files — in lazygit. For anyone who does not want two steps, `--commit-all` does it in one.
## The `git` tab in `Project>project`
It shows facts, read live from the folder and from Paperclip each time the pane draws:
```
repository yes, branch main, 1 commit
remote none (not needed for worktrees)
worktrees isolated per task (Paperclip policy: isolated_workspace, base main)
changed 8 staged · 0 unstaged · 3 untracked · 2 ignored
ignored .paperclip/ evidence/
next t → lazygit: `i` to ignore a file, `c` to commit
```
Two things it deliberately does **not** do:
- **it does not edit `.gitignore` itself.** lazygit already does it correctly, including `.git/info/exclude` and the guard on `.gitignore`. Two implementations of one job drift apart; one is enough.
- **it does not hold a copy of the state.** Same rule as everything else here: read it, do not remember it.
And a line in `Project>overview` pointing at it, so the tab is found rather than discovered.
## The banner
One line, only while something is wrong, in every pane:
```
no worktrees — no commit in this folder · Project>project → git (t: lazygit)
```
It disappears the moment a commit exists, so it cannot become furniture.
## What I would build
| | |
|---|---|
| Gate in `office` — two choices, write `.gitignore`, stage, stop before committing | **yes** |
| `--commit-all` for the one-step case | **yes** |
| `git` tab in `Project>project`: state, policy, counts, one-line next step | **yes** |
| One-line banner in every pane while not worktree-ready | **yes** |
| `.gitignore` editing inside our interface | **no** — lazygit does it, `t` opens it |
| Interesting complication: showing `staged` counts means running `git status` | read on refresh, on a button, not on every keystroke |
I have not touched `~/SECURITY`. It is still not a repository, and I will not run `office` on it — when the gate exists you can run it yourself and answer Y or N with everything in front of you.
## Evidence
- Confidence: 86%
- Reason: Preserve full sanitized discovery detail from a rich investigation turn
## Related
- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]]
## Related learned memories <!-- same-turn-2026-11-12 09:01:33 -->
- session: [[packs/paperclip-harness/80-sessions/rich-discovery-with-2026-11-12-09-01-33|Rich discovery: with 2026-11-12 09:01:33]]
- context: [[packs/paperclip-harness/20-context/with|with]]
- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|with repository structure and configuration patterns]]

Binary file not shown.

View File

@@ -67,3 +67,56 @@ def make_repo(folder, quiet=False):
return False, "git commit failed: %s" % detail[:200]
say(" made a repository on branch %s, one commit" % branch(folder))
return True, ""
STARTER = """# Paperclip puts each task's worktree inside this folder, at .paperclip/.
# It is a checkout of this repository, so it must never be committed.
.paperclip/
# Add your own patterns below, one per line. For example:
# evidence/
# .env
# *secret*
"""
def ensure_ignore(folder):
"""Make sure .gitignore exists and that it excludes .paperclip/.
Returns (changed, note). The .paperclip entry is not a preference: without
it the first commit takes the worktrees with it.
"""
path = os.path.join(folder, ".gitignore")
if not os.path.exists(path):
with open(path, "w") as fh:
fh.write(STARTER)
return True, "wrote .gitignore, ignoring .paperclip/"
with open(path) as fh:
text = fh.read()
for line in text.splitlines():
line = line.strip().strip("/")
if line == ".paperclip" or line.startswith(".paperclip/"):
return False, ".gitignore already ignores .paperclip/"
add = "" if text.endswith("\n") or not text else "\n"
add += "\n# Paperclip's task worktrees; a commit must never take them.\n.paperclip/\n"
with open(path, "a") as fh:
fh.write(add)
return True, "added .paperclip/ to the existing .gitignore"
def stage_all(folder):
r = git(folder, "add", "-A")
return r.returncode == 0
def commit_all(folder, message="Initial commit, before any Paperclip task"):
"""Commit what is staged. Returns (ok, message)."""
r = git(folder, "-c", "user.name=Paperclip Harness",
"-c", "user.email=harness@localhost",
"commit", "-q", "-m", message)
if r.returncode != 0:
detail = (r.stderr or r.stdout).strip()
if "nothing to commit" in detail:
return False, "nothing to commit: the folder has no files git will take"
return False, "git commit failed: %s" % detail[:200]
return True, ""

View File

@@ -46,7 +46,8 @@ def parse(argv):
"""
opts = {"folder": ".", "folder_given": False, "project": "", "session": "",
"dry": False, "host": "", "no_switch": False, "new": "", "remote": "",
"git_init": "--git-init" in argv, "anywhere": "--anywhere" in argv}
"git_init": "--git-init" in argv, "anywhere": "--anywhere" in argv,
"commit_all": "--commit-all" in argv, "git_init_commits": False}
positional = []
i = 0
while i < len(argv):
@@ -71,7 +72,7 @@ def parse(argv):
return opts
def create_project(name, folder, dry):
def create_project(name, folder, dry, worktrees=True):
"""Create a Paperclip project for a folder, with the worktree policy set.
Adopting a folder that Paperclip has never heard of needed two steps before:
@@ -80,7 +81,7 @@ def create_project(name, folder, dry):
# A folder that is a git repository gets a branch per task in its own
# worktree. A folder that is not cannot: asking for a worktree would create
# a project whose every run fails. Those keep the folder in place instead.
is_repo = rp.is_repo(folder) and rp.has_commit(folder)
is_repo = worktrees and rp.is_repo(folder) and rp.has_commit(folder)
body = {
"name": name,
"description": "Adopted from %s by bin/office." % folder,
@@ -90,6 +91,9 @@ def create_project(name, folder, dry):
"sourceType": "git_repo" if is_repo else "local_path",
"cwd": folder,
"isPrimary": True,
# The project workspace's own base ref, for the case where a task is
# run without the policy. Read from the folder, never assumed.
"defaultRef": rp.branch(folder) if is_repo else None,
},
}
if is_repo:
@@ -313,7 +317,8 @@ COMMANDS
office <folder> --session <name> name the session instead of the folder
office <folder> --project "<Name>" --host <machine> the folder is on that machine
office <folder> --new "<Name>" --dry say what would happen, write nothing
office <folder> --new "<Name>" --git-init make the folder a repository first
office <folder> --new "<Name>" (with no repository it asks Y or N)
office <folder> --new "<Name>" --commit-all make the repository and commit everything
office <folder> --new "<Name>" --anywhere allow it although agents cannot see it
board create any missing pane in this project's session
@@ -352,11 +357,20 @@ WORKTREES, AND WHY GIT MATTERS
A new project gets a worktree policy: Paperclip makes a branch and a separate
directory per task, so two tasks never change the same files, and there is a
diff to review and a merge to approve.
That needs a repository with at least one commit. When the folder is not one,
office says so and asks before touching anything: making a repository means
committing every file in the folder, which is a decision about your own files.
Without it the project is created in place (local_path): a task edits the
folder directly, there is no diff and nothing to merge.
That needs a repository WITH AT LEAST ONE COMMIT, because a worktree is a
checkout of a commit: anything not committed is invisible to the agent.
When the folder cannot do that yet, office asks, and never writes history:
Y make the repository, write .gitignore containing .paperclip/, stage
everything, and stop. Commit in lazygit, then run this again.
N create the project in place (local_path): a task edits the folder
directly, there is no diff and nothing to merge.
Y leaves you in lazygit's hands on purpose: what you commit is what the agent
will see, and .gitignore is how you keep a file out of its view.
--commit-all does the whole thing in one go, for anyone who does not care.
The base branch is read from the folder, not assumed to be main.
WHERE THINGS RUN
@@ -381,6 +395,7 @@ AFTER A REBOOT
KEYS INSIDE THE PANES
( ) change tab tab change panel enter open / choose
r re-read Paperclip R rebuild all panes ? list the keys
t open lazygit in the project folder (stage, ignore, commit)
h j k l z n and digits belong to TUIOS and never reach the program.
MORE
@@ -431,51 +446,98 @@ def guard_machine(folder, opts):
def prepare_git(folder, opts):
"""Make the folder able to hold a worktree per task, and say what it did.
"""Get the folder as far as it can go toward a worktree per task.
A worktree needs a repository and at least one commit. Neither is created
silently: a first commit is a decision about a person's own files, and for a
folder of notes it may include things that should not be in a history at
all. Returns True when the folder is ready for worktrees.
A worktree needs a repository and at least one commit. Neither is made
silently: the first commit decides what every agent will be able to see, and
taking it is the person's decision.
Returns one of:
"ready" a repository with a commit: the project is created with worktrees
"staged" the repository and .gitignore are made and everything is staged,
but not committed, so the project is not created yet
"no" leave it alone: the project is created in place, without worktrees
"""
if rp.is_repo(folder) and rp.has_commit(folder):
return True
if rp.is_repo(folder) and not rp.has_commit(folder):
what = "the repository at %s has no commit yet" % folder
else:
what = "%s is not a git repository" % folder
note_ignore(folder, opts)
return "ready"
if opts["dry"]:
return False
h.say("%s, so each task cannot have its own branch and worktree." % what)
h.say(" without them, two tasks can change the same files at once, and there")
return "ready" if rp.is_repo(folder) and rp.has_commit(folder) else "no"
what = ("%s is not a git repository" % folder if not rp.is_repo(folder)
else "the repository at %s has no commit yet" % folder)
h.say("%s, so a task cannot have its own branch and worktree." % what)
h.say(" without one, two tasks can change the same files at once, and there")
h.say(" is no diff to review and nothing to merge.")
if opts["git_init"]:
go = True
elif sys.stdin.isatty():
h.say("")
h.say("make it a repository now? This runs: git init -b main, git add -A,")
h.say("and one commit of EVERY file in the folder, including anything you")
h.say("have not noticed. Add a .gitignore first if that is not what you want.")
answer = input(" type yes to do it: ").strip().lower()
go = answer in ("y", "yes")
else:
go = False
h.say("")
if not (opts["git_init"] or opts["commit_all"]):
if not sys.stdin.isatty():
h.say("not a terminal, so nothing was changed. Either run this again and")
h.say("answer the question, or do it yourself:")
h.say("answer the question, or do it yourself and pass --project later:")
h.say(" cd %s" % folder)
if rp.is_repo(folder):
if not rp.is_repo(folder):
h.say(" git init -b main")
h.say(" printf '.paperclip/\\n' >> .gitignore")
h.say(" git add -A && git commit -m 'Initial commit'")
else:
h.say(" git init -b main && git add -A && git commit -m 'Initial commit'")
h.say("or pass --git-init to let this command do it.")
if not go:
return False
ok, why = rp.make_repo(folder)
h.say("or pass --commit-all to let this command do all of it.")
raise h.Refused(2, "no repository and no terminal to ask in")
h.say(" Y make the repository, write .gitignore with .paperclip/,")
h.say(" stage everything, and stop. You commit it in lazygit, then")
h.say(" run this command again to create the project with worktrees.")
h.say(" N create the project in place instead: no branches, no diff,")
h.say(" nothing to merge.")
h.say("")
answer = input(" Y or N: ").strip().lower()
if answer not in ("y", "yes"):
h.say(" left alone. Creating the project in place, without worktrees.")
return "no"
# Y, --git-init, or --commit-all from here on.
if not rp.is_repo(folder):
r = rp.git(folder, "init", "-b", "main")
if r.returncode != 0:
raise h.Refused(2, "git init failed: %s" % (r.stderr or r.stdout).strip()[:200])
h.say(" made a repository on branch %s" % rp.branch(folder))
changed, note = rp.ensure_ignore(folder)
h.say(" %s" % note)
if not rp.stage_all(folder):
raise h.Refused(2, "git add failed in %s" % folder)
if opts["commit_all"] or (opts["git_init"] and opts["git_init_commits"]):
ok, why = rp.commit_all(folder)
if not ok:
h.say(" could not: %s" % why)
return False
return True
h.say(" could not commit: %s" % why)
return "staged"
h.say(" committed everything on branch %s" % rp.branch(folder))
return "ready"
h.say("")
h.say(" Everything is staged and nothing is committed. Check what git is about")
h.say(" to take, and commit it:")
h.say("")
h.say(" cd %s && lazygit" % folder)
h.say(" space stage or unstage a file i ignore a file or folder")
h.say(" c commit ? every key")
h.say("")
h.say(" Then run this command again. It will create the project with worktrees.")
return "staged"
def note_ignore(folder, opts):
"""Say when worktrees are about to be committed. Never change anything."""
state = rp.is_repo(folder)
if not state:
return
path = os.path.join(folder, ".gitignore")
text = open(path).read() if os.path.exists(path) else ""
for line in text.splitlines():
trimmed = line.strip().strip("/")
if trimmed == ".paperclip" or trimmed.startswith(".paperclip/"):
return
h.say(" warning: .paperclip/ is not in .gitignore, so a commit here would")
h.say(" take Paperclip's task worktrees with it. Add it before you commit.")
def main():
@@ -532,10 +594,11 @@ def main():
h.say("would create a Paperclip project %r for %s" % (opts["new"], folder))
else:
h.say("would adopt the Paperclip project %r for %s" % (opts["project"] or "<from .harness/harness.toml>", folder))
if ready:
if ready == "ready":
h.say("worktrees: yes — a repository on branch %r, so each task gets its own" % rp.branch(folder))
else:
h.say("worktrees: no — %s" % ("this is not a repository" if not rp.is_repo(folder) else "the repository has no commit"))
h.say("worktrees: no — %s" % ("this is not a repository" if not rp.is_repo(folder)
else "the repository has no commit"))
h.say("would write %s" % os.path.join(folder, ".harness", "harness.toml"))
h.say("would create or reuse session %r and build eight panes there" % session)
h.say("dry run: nothing was written")
@@ -545,8 +608,15 @@ def main():
# Only a note, because the panes are built by the machine that owns them.
h.say("host: %s (build the pages there)" % host)
if ready == "staged":
# Nothing is committed, so the project cannot have worktrees yet, and
# creating it without them is a decision that cannot be undone.
h.say("")
h.say("the project was not created: commit the staged files, then run this again.")
return 0
if opts["new"]:
ref = create_project(opts["new"], folder, opts["dry"])
ref = create_project(opts["new"], folder, opts["dry"], ready == "ready")
if not ref:
return 1
opts["project"] = ref

View File

@@ -72,36 +72,100 @@ Inside a pane, `R` in `Repair>repair` rebuilds everything; `B` only adds what is
A new project gets a worktree policy: Paperclip makes a branch and a separate directory per task,
so two tasks never change the same files, and there is a diff to review and a merge to approve.
That needs a repository **with at least one commit**. `office` checks, and when the folder is not
one it explains what is lost and asks before touching anything:
That needs a repository **with at least one commit**, because a worktree is a checkout of a commit:
anything not committed is invisible to the agent. When the folder cannot do that yet, `office`
asks, and never writes history:
```
/tmp/officetest is not a git repository, so each task cannot have its own branch and worktree.
without them, two tasks can change the same files at once, and there
/tmp/gate1 is not a git repository, so a task cannot have its own branch and worktree.
without one, two tasks can change the same files at once, and there
is no diff to review and nothing to merge.
make it a repository now? This runs: git init -b main, git add -A,
and one commit of EVERY file in the folder, including anything you
have not noticed. Add a .gitignore first if that is not what you want.
type yes to do it:
Y make the repository, write .gitignore with .paperclip/,
stage everything, and stop. You commit it in lazygit, then
run this command again to create the project with worktrees.
N create the project in place instead: no branches, no diff,
nothing to merge.
Y or N:
```
Three answers, in order of preference:
Answer **Y** and it stops before committing, on purpose:
1. **Answer the question.** It only appears at a terminal, so nothing is created by a script by
accident.
2. **`--git-init`** does the same without asking, for a script.
3. **Do it yourself**, which is what a machine without a terminal is told to do, with the exact
commands.
```
made a repository on branch main
wrote .gitignore, ignoring .paperclip/
If you say no, or there is no terminal and no flag, the project is still created — as
`local_path`, in place. A task then edits the folder directly: no branch, no diff, nothing to
merge. That is honest for a folder of notes and wrong for code.
Everything is staged and nothing is committed. Check what git is about
to take, and commit it:
The base branch is read from the folder (`git symbolic-ref`), not assumed to be `main`, so a
repository on `master` works.
cd /tmp/gate1 && lazygit
space stage or unstage a file i ignore a file or folder
c commit ? every key
## A folder on a machine that does not serve Paperclip
Then run this command again. It will create the project with worktrees.
the project was not created: commit the staged files, then run this again.
```
Two decisions are kept out of the tool's hands, because both belong to the person:
- **what gets committed**, because what you commit is what the agent can see. `lazygit`'s files
panel stages and unstages with `space`, and **`i` adds a file or folder to `.gitignore`** — for a
pattern, `e` opens the file. The first commit is unpushed, so `A` amends it if you change your
mind.
- **whether there is a commit at all**. No commit means no worktrees, and `N` creates the project
in place (`local_path`) instead: a task edits the folder directly, with no diff and nothing to
merge. A project's policy is fixed when it is created, so that choice cannot be revisited
without making the project again.
`--commit-all` does the whole thing in one step for anyone who does not want to look. With no
terminal and no flag, `office` changes nothing and prints the three commands instead.
**`.paperclip/` is written into `.gitignore` on every path and is not a preference.** Paperclip
puts its worktrees inside the project folder; a commit that took them would put checkouts of the
repository inside the repository.
The base branch is read from the folder (`git symbolic-ref`), not assumed to be `main`, and it is
set in both places Paperclip keeps it: the policy's `baseRef` and the workspace's `defaultRef`.
## Seeing the state: the `git` tab, and the banner
`Project>project` → `(` `)` → **git** reads the folder and Paperclip together:
```
THIS FOLDER
repository yes · branch main
commits 3
remote none — not needed for worktrees
changed 0 staged · 0 unstaged · 2 untracked
.gitignore .paperclip/
worktrees isolated_workspace · git_worktree · base main
WORKTREES, AS PAPERCLIP SEES THEM
AUD-172-add-a-status-md-with-one-line clean · 1 behind · merged into base
This workspace is 1 commit behind main.
```
Two halves, deliberately: the top is git run in this folder, which Paperclip never checks; the
bottom is Paperclip's own scan of each task's worktree
(`GET /api/execution-workspaces/{id}/close-readiness`), which already knows the branch, what is
dirty, how far behind the base it is, and whether it is merged. Nothing is recomputed.
`t` opens `lazygit` in the project folder, wherever the git tab says something needs doing.
While the folder cannot hold a worktree, **every pane** carries one line at the top:
```
commit will swallow the worktrees — .paperclip/ is not in .gitignore · Project>project → git
```
It takes the page title's line and leaves the panels and the key hints alone. It is read once when
the pane starts, so fixing the folder and pressing `R` (which rebuilds every pane) is what clears
it.
## A folder on a machine that does not serve Paperclip## A folder on a machine that does not serve Paperclip
Panes run where the folder is. **Agents run where Paperclip is**, because Paperclip starts them
itself. When those are different machines the panes read the folder correctly and the agents

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
}