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:
@@ -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]]
|
||||
|
||||
@@ -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]]
|
||||
|
||||
160
.pi/memory-vault/packs/paperclip-harness/20-context/with.md
Normal file
160
.pi/memory-vault/packs/paperclip-harness/20-context/with.md
Normal 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]]
|
||||
@@ -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]]
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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]]
|
||||
@@ -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]]
|
||||
@@ -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]]
|
||||
@@ -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]]
|
||||
BIN
bin/harness
BIN
bin/harness
Binary file not shown.
@@ -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, ""
|
||||
|
||||
172
bin/office
172
bin/office
@@ -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("")
|
||||
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(" cd %s" % folder)
|
||||
if rp.is_repo(folder):
|
||||
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 and pass --project later:")
|
||||
h.say(" cd %s" % 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)
|
||||
if not ok:
|
||||
h.say(" could not: %s" % why)
|
||||
return False
|
||||
return True
|
||||
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 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
|
||||
|
||||
104
docs/STARTUP.md
104
docs/STARTUP.md
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
@@ -56,3 +57,58 @@ func TestTruncateVisibleKeepsStylingAndWidth(t *testing.T) {
|
||||
t.Fatalf("truncation should say it truncated: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannerIsDrawnOnceAndKeepsTheHeight(t *testing.T) {
|
||||
defer func(prev string) { bannerText = prev }(bannerText)
|
||||
bannerText = "no worktrees — this folder is not a git repository"
|
||||
const w, h = 80, 24
|
||||
|
||||
// A screen with three panels side by side, the way the board is drawn.
|
||||
one := panel{title: "a", width: 26, height: h - 2}.render([]string{"x"})
|
||||
two := panel{title: "b", width: 26, height: h - 2}.render([]string{"y"})
|
||||
three := panel{title: "c", width: 26, height: h - 2}.render([]string{"z"})
|
||||
inner := strings.Join([]string{
|
||||
strings.Repeat(" ", w),
|
||||
sideBySide(one, two, three),
|
||||
"footer",
|
||||
}, "\n")
|
||||
|
||||
b := bannerModel{inner: stubModel{view: inner}, width: w, height: h}
|
||||
got := lines(b.View())
|
||||
if len(got) != h {
|
||||
t.Fatalf("got %d lines, want %d", len(got), h)
|
||||
}
|
||||
if n := strings.Count(strings.Join(got, "\n"), "no worktrees"); n != 1 {
|
||||
t.Fatalf("the warning appears %d times, want once", n)
|
||||
}
|
||||
if !strings.Contains(strings.Join(got, "\n"), "footer") {
|
||||
t.Fatal("the key hints were dropped; only the title line should give way")
|
||||
}
|
||||
if lipgloss.Width(got[0]) != w {
|
||||
t.Fatalf("the warning line is %d wide, want %d", lipgloss.Width(got[0]), w)
|
||||
}
|
||||
}
|
||||
|
||||
type stubModel struct{ view string }
|
||||
|
||||
func (s stubModel) Init() tea.Cmd { return nil }
|
||||
func (s stubModel) Update(tea.Msg) (tea.Model, tea.Cmd) { return s, nil }
|
||||
func (s stubModel) View() string { return s.view }
|
||||
|
||||
func sideBySide(parts ...string) string {
|
||||
rows := make([][]string, len(parts))
|
||||
for i, p := range parts {
|
||||
rows[i] = lines(p)
|
||||
}
|
||||
var out []string
|
||||
for r := range rows[0] {
|
||||
var b strings.Builder
|
||||
for _, cols := range rows {
|
||||
if r < len(cols) {
|
||||
b.WriteString(cols[r])
|
||||
}
|
||||
}
|
||||
out = append(out, b.String())
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
263
tui/gitinfo.go
Normal file
263
tui/gitinfo.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package main
|
||||
|
||||
// What git says about the project folder, and what Paperclip says about the
|
||||
// worktree each task got.
|
||||
//
|
||||
// Two layers, deliberately:
|
||||
//
|
||||
// readGitState runs git in the folder. Paperclip does not check that the
|
||||
// folder is a repository at all, so this is the only early
|
||||
// warning a person can get.
|
||||
// readWorktrees asks Paperclip to scan each execution workspace. That scan
|
||||
// already knows the branch, what is dirty, how far behind the
|
||||
// base it is and whether it is merged — so nothing here is
|
||||
// recomputed.
|
||||
//
|
||||
// Every git command in this file runs inside a tea.Cmd, never in Update or View.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"paperclip-harness/tui/paperclip"
|
||||
)
|
||||
|
||||
// gitState is the folder as git sees it today.
|
||||
type gitState struct {
|
||||
IsRepo bool
|
||||
HasCommit bool
|
||||
Branch string
|
||||
Commits int
|
||||
Remotes []string
|
||||
Staged int
|
||||
Unstaged int
|
||||
Untracked int
|
||||
HasIgnore bool
|
||||
Ignore []string
|
||||
IgnoreErr string
|
||||
BehindBase int
|
||||
AheadBase int
|
||||
}
|
||||
|
||||
func gitOut(folder string, args ...string) (string, bool) {
|
||||
cmd := exec.Command("git", append([]string{"-C", folder}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimRight(string(out), "\n"), true
|
||||
}
|
||||
|
||||
// readGitState reads the folder. Safe to call from a goroutine.
|
||||
func readGitState(folder string) gitState {
|
||||
var g gitState
|
||||
if _, ok := gitOut(folder, "rev-parse", "--is-inside-work-tree"); !ok {
|
||||
return g
|
||||
}
|
||||
g.IsRepo = true
|
||||
if out, ok := gitOut(folder, "rev-parse", "--verify", "HEAD"); ok && out != "" {
|
||||
g.HasCommit = true
|
||||
}
|
||||
if out, ok := gitOut(folder, "symbolic-ref", "--short", "HEAD"); ok {
|
||||
g.Branch = out
|
||||
}
|
||||
if out, ok := gitOut(folder, "rev-list", "--count", "HEAD"); ok {
|
||||
g.Commits, _ = strconv.Atoi(strings.TrimSpace(out))
|
||||
}
|
||||
if out, ok := gitOut(folder, "remote"); ok && out != "" {
|
||||
g.Remotes = strings.Split(out, "\n")
|
||||
}
|
||||
if out, ok := gitOut(folder, "status", "--porcelain=v1"); ok {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if len(line) < 3 {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "??"):
|
||||
g.Untracked++
|
||||
case line[0] != ' ':
|
||||
g.Staged++
|
||||
default:
|
||||
g.Unstaged++
|
||||
}
|
||||
}
|
||||
}
|
||||
ignorePath := filepath.Join(folder, ".gitignore")
|
||||
if b, err := os.ReadFile(ignorePath); err == nil {
|
||||
g.HasIgnore = true
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
g.Ignore = append(g.Ignore, line)
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// worktreeRow is one task's directory, as Paperclip describes it.
|
||||
type worktreeRow struct {
|
||||
Issue string
|
||||
Branch string
|
||||
Path string
|
||||
Dirty int
|
||||
Ahead int
|
||||
Behind int
|
||||
Merged bool
|
||||
State string
|
||||
Warning string
|
||||
}
|
||||
|
||||
// readWorktrees asks Paperclip for this project's task workspaces and their git
|
||||
// scan. Newest first, and only the ones still in use, because each row costs a
|
||||
// request and an old number is not worth one.
|
||||
func readWorktrees(c *paperclip.Client, projectID string, limit int) ([]worktreeRow, error) {
|
||||
all, err := c.ExecutionWorkspaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var mine []paperclip.ExecutionWorkspace
|
||||
for _, w := range all {
|
||||
if w.ProjectID != projectID {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(w.Status, "closed") || strings.EqualFold(w.Status, "archived") {
|
||||
continue
|
||||
}
|
||||
mine = append(mine, w)
|
||||
}
|
||||
sort.SliceStable(mine, func(i, j int) bool { return mine[i].LastUsedAt > mine[j].LastUsedAt })
|
||||
if limit > 0 && len(mine) > limit {
|
||||
mine = mine[:limit]
|
||||
}
|
||||
out := make([]worktreeRow, 0, len(mine))
|
||||
for _, w := range mine {
|
||||
row := worktreeRow{Branch: w.BranchName, Path: w.ProviderRef}
|
||||
if row.Path == "" {
|
||||
row.Path = w.Cwd
|
||||
}
|
||||
if rr, err := c.CloseReadinessFor(w.ID); err == nil {
|
||||
row.Branch = rr.Git.BranchName
|
||||
if rr.Git.WorkspacePath != "" {
|
||||
row.Path = rr.Git.WorkspacePath
|
||||
}
|
||||
row.Dirty = rr.Git.DirtyEntryCount + rr.Git.UntrackedEntryCount
|
||||
row.Ahead = rr.Git.AheadCount
|
||||
row.Behind = rr.Git.BehindCount
|
||||
row.Merged = rr.Git.IsMergedIntoBase
|
||||
row.State = rr.State
|
||||
if len(rr.Warnings) > 0 {
|
||||
row.Warning = rr.Warnings[0]
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ready reports whether this folder can give each task its own worktree.
|
||||
func (g gitState) ready() bool { return g.IsRepo && g.HasCommit }
|
||||
|
||||
// ignoresPaperclipWorktrees finds the one entry that must never be missing: the
|
||||
// folder Paperclip puts worktrees in. Without it a first commit swallows them.
|
||||
func (g gitState) ignoresPaperclipWorktrees() bool {
|
||||
for _, p := range g.Ignore {
|
||||
trimmed := strings.Trim(p, "/")
|
||||
if trimmed == ".paperclip" || strings.HasPrefix(trimmed, ".paperclip/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// banner is the one line shown in every pane while this folder cannot hold a
|
||||
// task worktree. Empty when it can.
|
||||
func bannerFor(g gitState) string {
|
||||
if g.ready() && g.ignoresPaperclipWorktrees() {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case !g.IsRepo:
|
||||
return "no worktrees — this folder is not a git repository · Project>project → git"
|
||||
case !g.HasCommit:
|
||||
return "no worktrees — this repository has no commit · Project>project → git"
|
||||
default:
|
||||
return "commit will swallow the worktrees — .paperclip/ is not in .gitignore · Project>project → git"
|
||||
}
|
||||
}
|
||||
|
||||
// bannerText is set once at startup, before the terminal is taken over, so no
|
||||
// git command ever runs inside Update or View. Fixing the folder and pressing R
|
||||
// (which restarts every pane) is what clears it.
|
||||
var bannerText string
|
||||
|
||||
func setGitBanner(folder string) { bannerText = bannerFor(readGitState(folder)) }
|
||||
|
||||
// today is here so the file has one small helper used by the tab, and so the
|
||||
// tab and the banner cannot drift apart in how they describe the same thing.
|
||||
func today() string { return time.Now().Format("15:04") }
|
||||
|
||||
// bannerLine is one full-width line, cut rather than wrapped.
|
||||
func bannerLine(text string, width int) string {
|
||||
if width <= 0 {
|
||||
return ""
|
||||
}
|
||||
if lipgloss.Width(text) > width {
|
||||
text = truncateVisible(text, width)
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Foreground(lipgloss.Color(active.Warning)).
|
||||
Background(lipgloss.Color(active.Surface)).
|
||||
Render(text)
|
||||
}
|
||||
|
||||
// withBanner wraps a screen so that the setup warning appears once, at the top,
|
||||
// whatever the screen is made of. The board draws three panels side by side, so
|
||||
// drawing the banner inside a panel put it there three times.
|
||||
//
|
||||
// It takes the line the screen would have drawn first — the page title and the
|
||||
// read time — and gives that line to the warning. The panels and the key hints
|
||||
// are left alone, because those are what a person needs while fixing the
|
||||
// problem.
|
||||
type bannerModel struct {
|
||||
inner tea.Model
|
||||
width, height int
|
||||
}
|
||||
|
||||
func (b bannerModel) Init() tea.Cmd { return b.inner.Init() }
|
||||
|
||||
func (b bannerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if w, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
b.width, b.height = w.Width, w.Height
|
||||
}
|
||||
next, cmd := b.inner.Update(msg)
|
||||
b.inner = next
|
||||
return b, cmd
|
||||
}
|
||||
|
||||
func (b bannerModel) View() string {
|
||||
view := b.inner.View()
|
||||
if bannerText == "" || b.width <= 0 {
|
||||
return view
|
||||
}
|
||||
lines := strings.Split(view, "\n")
|
||||
if len(lines) > 1 {
|
||||
lines = lines[1:] // the warning takes the title line
|
||||
}
|
||||
out := append([]string{bannerLine(bannerText, b.width)}, lines...)
|
||||
if b.height > 0 && len(out) > b.height {
|
||||
out = out[:b.height]
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
11
tui/main.go
11
tui/main.go
@@ -40,6 +40,14 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "warning: PAPERCLIP_BOARD_KEY is not set, so replies and status changes will be refused")
|
||||
}
|
||||
|
||||
// One git read, here, before the terminal is taken over: the banner in every
|
||||
// pane depends on it and no git command may run inside Update or View.
|
||||
screenFolder := *folder
|
||||
if screenFolder == "" {
|
||||
screenFolder, _ = os.Getwd()
|
||||
}
|
||||
setGitBanner(screenFolder)
|
||||
|
||||
var m tea.Model
|
||||
switch *screen {
|
||||
case "board":
|
||||
@@ -138,7 +146,8 @@ func main() {
|
||||
|
||||
// Alternate screen: TUIOS keeps its own scrollback, and a full-screen
|
||||
// program that redraws in place must not fight it.
|
||||
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
// The setup warning is drawn once for the whole screen, not once per panel.
|
||||
p := tea.NewProgram(bannerModel{inner: m}, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -221,6 +221,23 @@ type Project struct {
|
||||
Name string `json:"name"`
|
||||
URLKey string `json:"urlKey"`
|
||||
Status string `json:"status"`
|
||||
|
||||
// The worktree policy is fixed when the project is created, so the screen
|
||||
// reports it rather than offering to change it.
|
||||
ExecutionWorkspacePolicy *struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
DefaultMode string `json:"defaultMode"`
|
||||
Strategy struct {
|
||||
Type string `json:"type"`
|
||||
Base string `json:"baseRef"`
|
||||
} `json:"workspaceStrategy"`
|
||||
} `json:"executionWorkspacePolicy"`
|
||||
|
||||
Workspaces []struct {
|
||||
SourceType string `json:"sourceType"`
|
||||
Cwd string `json:"cwd"`
|
||||
DefaultRef string `json:"defaultRef"`
|
||||
} `json:"workspaces"`
|
||||
}
|
||||
|
||||
// Comment is one entry on a task's thread.
|
||||
@@ -615,3 +632,76 @@ func (c *Client) ImportSkill(source string) (string, error) {
|
||||
}
|
||||
return "", fmt.Errorf("import returned no skill")
|
||||
}
|
||||
|
||||
// ExecutionWorkspace is one task's own directory: a git worktree, or a shared
|
||||
// folder when the project does not use worktrees. Paperclip makes these, not us.
|
||||
type ExecutionWorkspace struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ProjectID string `json:"projectId"`
|
||||
SourceIssueID string `json:"sourceIssueId"`
|
||||
BranchName string `json:"branchName"`
|
||||
BaseRef string `json:"baseRef"`
|
||||
ProviderType string `json:"providerType"`
|
||||
ProviderRef string `json:"providerRef"`
|
||||
Mode string `json:"mode"`
|
||||
Status string `json:"status"`
|
||||
DeliveryState string `json:"deliveryState"`
|
||||
Cwd string `json:"cwd"`
|
||||
LastUsedAt string `json:"lastUsedAt"`
|
||||
}
|
||||
|
||||
// CloseReadiness is Paperclip's own git scan of one execution workspace, plus
|
||||
// what it would do if the workspace were closed. The git half is the only place
|
||||
// in Paperclip that reports branch, dirtiness and how far behind a worktree is.
|
||||
type CloseReadiness struct {
|
||||
State string `json:"state"`
|
||||
DeliveryState string `json:"deliveryState"`
|
||||
BlockingReasons []string `json:"blockingReasons"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Git struct {
|
||||
RepoRoot string `json:"repoRoot"`
|
||||
WorkspacePath string `json:"workspacePath"`
|
||||
BranchName string `json:"branchName"`
|
||||
BaseRef string `json:"baseRef"`
|
||||
HasDirtyTrackedFiles bool `json:"hasDirtyTrackedFiles"`
|
||||
HasUntrackedFiles bool `json:"hasUntrackedFiles"`
|
||||
DirtyEntryCount int `json:"dirtyEntryCount"`
|
||||
UntrackedEntryCount int `json:"untrackedEntryCount"`
|
||||
AheadCount int `json:"aheadCount"`
|
||||
BehindCount int `json:"behindCount"`
|
||||
IsMergedIntoBase bool `json:"isMergedIntoBase"`
|
||||
CreatedByRuntime bool `json:"createdByRuntime"`
|
||||
} `json:"git"`
|
||||
}
|
||||
|
||||
// ExecutionWorkspaces lists every task workspace in the company. They are
|
||||
// filtered per project here rather than by the API, which has no such filter.
|
||||
func (c *Client) ExecutionWorkspaces() ([]ExecutionWorkspace, error) {
|
||||
var raw json.RawMessage
|
||||
if err := c.do("GET", "/api/companies/"+c.Company+"/execution-workspaces", nil, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var list []ExecutionWorkspace
|
||||
if err := json.Unmarshal(raw, &list); err == nil {
|
||||
return list, nil
|
||||
}
|
||||
var wrapper struct {
|
||||
Workspaces []ExecutionWorkspace `json:"workspaces"`
|
||||
Data []ExecutionWorkspace `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrapper); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(wrapper.Workspaces) > 0 {
|
||||
return wrapper.Workspaces, nil
|
||||
}
|
||||
return wrapper.Data, nil
|
||||
}
|
||||
|
||||
// CloseReadinessFor asks Paperclip to scan one execution workspace's git state.
|
||||
func (c *Client) CloseReadinessFor(id string) (CloseReadiness, error) {
|
||||
var out CloseReadiness
|
||||
err := c.do("GET", "/api/execution-workspaces/"+id+"/close-readiness", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
180
tui/project.go
180
tui/project.go
@@ -21,6 +21,8 @@ import (
|
||||
)
|
||||
|
||||
type projectMsg struct {
|
||||
git gitState
|
||||
wts []worktreeRow
|
||||
dash paperclip.Dashboard
|
||||
project paperclip.Project
|
||||
cfg map[string]string
|
||||
@@ -50,6 +52,9 @@ type projectModel struct {
|
||||
err error
|
||||
readAt time.Time
|
||||
|
||||
git gitState
|
||||
wts []worktreeRow
|
||||
|
||||
tab int
|
||||
scroll int
|
||||
regAt int
|
||||
@@ -60,7 +65,18 @@ type projectModel struct {
|
||||
width, height int
|
||||
}
|
||||
|
||||
var projectTabs = []string{"overview", "facts", "costs", "settings", "projects"}
|
||||
// Tab order is also the reading order, and the numbers are named because they
|
||||
// are used in the key handling: a bare 4 broke once already.
|
||||
const (
|
||||
tabOverview = iota
|
||||
tabGit
|
||||
tabFacts
|
||||
tabCosts
|
||||
tabSettings
|
||||
tabProjects
|
||||
)
|
||||
|
||||
var projectTabs = []string{"overview", "git", "facts", "costs", "settings", "projects"}
|
||||
|
||||
func newProjectModel(c *paperclip.Client, folder string) projectModel {
|
||||
return projectModel{client: c, folder: folder}
|
||||
@@ -104,7 +120,10 @@ func (m projectModel) load() tea.Cmd {
|
||||
costs = append(costs, taskCost{t.Identifier, t.Status, cost.CostCents, cost.RunCount})
|
||||
}
|
||||
dash, _ := c.DashboardSummary()
|
||||
return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs, dash: dash}
|
||||
git := readGitState(folder)
|
||||
wts, _ := readWorktrees(c, proj.ID, 8)
|
||||
return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs,
|
||||
dash: dash, git: git, wts: wts}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +203,13 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
case "r":
|
||||
return m, m.load()
|
||||
case "t":
|
||||
// lazygit, in the project folder: it is the tool that can stage,
|
||||
// exclude and commit. Nothing here reimplements it.
|
||||
if err := openTool(m.folder, "lazygit"); err != nil {
|
||||
m.err = err
|
||||
}
|
||||
return m, nil
|
||||
case "R":
|
||||
if err := startRebuild(m.folder); err != nil {
|
||||
m.err = err
|
||||
@@ -191,7 +217,7 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
if m.tab == 4 {
|
||||
if m.tab == tabProjects {
|
||||
m.regs = readRegistry()
|
||||
if len(m.regs) > 0 && m.regAt < len(m.regs) {
|
||||
m.confirmSwitch = true
|
||||
@@ -211,13 +237,13 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
if m.tab == 4 && m.regAt > 0 {
|
||||
if m.tab == tabProjects && m.regAt > 0 {
|
||||
m.regAt--
|
||||
}
|
||||
m.scroll--
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
if m.tab == 4 && m.regAt < len(m.regs)-1 {
|
||||
if m.tab == tabProjects && m.regAt < len(m.regs)-1 {
|
||||
m.regAt++
|
||||
}
|
||||
m.scroll++
|
||||
@@ -234,6 +260,8 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.cfg = msg.cfg
|
||||
m.agents = msg.agents
|
||||
m.costs = msg.costs
|
||||
m.git = msg.git
|
||||
m.wts = msg.wts
|
||||
m.err = msg.err
|
||||
m.readAt = time.Now()
|
||||
return m, nil
|
||||
@@ -263,7 +291,9 @@ func (m projectModel) View() string {
|
||||
}
|
||||
if m.showHelp {
|
||||
return helpOverlay("the project", [][2]string{
|
||||
{"[ ] h l", "facts, costs, settings"},
|
||||
{"( )", "overview, git, facts, costs, settings, projects"},
|
||||
{"t", "open lazygit in this folder"},
|
||||
{"[ ] h l", "previous and next tab"},
|
||||
{"↑ ↓ j k", "scroll"},
|
||||
{"r", "read again"},
|
||||
{"R", "rebuild every pane"},
|
||||
@@ -283,13 +313,15 @@ func (m projectModel) View() string {
|
||||
|
||||
var body []string
|
||||
switch m.tab {
|
||||
case 0:
|
||||
case tabOverview:
|
||||
body = m.overviewLines(m.width - 4)
|
||||
case 1:
|
||||
case tabGit:
|
||||
body = m.gitLines(m.width - 4)
|
||||
case tabFacts:
|
||||
body = m.factLines()
|
||||
case 2:
|
||||
case tabCosts:
|
||||
body = m.costLines()
|
||||
case 4:
|
||||
case tabProjects:
|
||||
body = m.projectListLines(m.width - 4)
|
||||
default:
|
||||
body = m.settingLines()
|
||||
@@ -312,6 +344,7 @@ func (m projectModel) View() string {
|
||||
|
||||
footer := hintLine([]string{
|
||||
keyHint("( )", "tab"),
|
||||
keyHint("t", "lazygit"),
|
||||
keyHint("↑↓", "scroll"),
|
||||
keyHint("r", "refresh"),
|
||||
keyHint("R", "rebuild"),
|
||||
@@ -574,3 +607,130 @@ func (m projectModel) projectListLines(width int) []string {
|
||||
out = append(out, dimStyle().Render(" ~/.config/tuios hosts table; otherwise attach to it by hand."))
|
||||
return out
|
||||
}
|
||||
|
||||
// gitLines is the git tab: the folder read locally, then what Paperclip knows
|
||||
// about each task's worktree. The two halves answer different questions and are
|
||||
// not merged into one verdict.
|
||||
func (m projectModel) gitLines(width int) []string {
|
||||
var out []string
|
||||
g := m.git
|
||||
|
||||
report := func(label, value string, bad bool) {
|
||||
line := " " + mutedStyle2().Render(fmt.Sprintf("%-14s", label))
|
||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text))
|
||||
if bad {
|
||||
style = style.Foreground(lipgloss.Color(active.Warning))
|
||||
}
|
||||
out = append(out, line+style.Render(value))
|
||||
}
|
||||
|
||||
// --- the folder ---
|
||||
out = append(out, mutedStyle2().Render(" THIS FOLDER"))
|
||||
if !g.IsRepo {
|
||||
report("repository", "no — this folder is not a git repository", true)
|
||||
} else {
|
||||
branch := g.Branch
|
||||
if branch == "" {
|
||||
branch = "(no branch yet)"
|
||||
}
|
||||
report("repository", "yes · branch "+branch, false)
|
||||
report("commits", fmt.Sprintf("%d", g.Commits), !g.HasCommit)
|
||||
if g.Commits == 0 {
|
||||
out = append(out, " no commit, so nothing to cut a worktree from")
|
||||
}
|
||||
remotes := "none — not needed for worktrees"
|
||||
if len(g.Remotes) > 0 {
|
||||
remotes = strings.Join(g.Remotes, ", ")
|
||||
}
|
||||
report("remote", remotes, false)
|
||||
report("changed", fmt.Sprintf("%d staged · %d unstaged · %d untracked",
|
||||
g.Staged, g.Unstaged, g.Untracked), false)
|
||||
if g.HasIgnore {
|
||||
shown := strings.Join(g.Ignore, " ")
|
||||
if len(shown) > width-24 {
|
||||
shown = shown[:width-25] + "…"
|
||||
}
|
||||
if shown == "" {
|
||||
shown = "(no entries)"
|
||||
}
|
||||
report(".gitignore", shown, !g.ignoresPaperclipWorktrees())
|
||||
} else {
|
||||
report(".gitignore", "missing — .paperclip/ must be in it", true)
|
||||
}
|
||||
}
|
||||
out = append(out, "")
|
||||
|
||||
// --- what Paperclip will do with each task ---
|
||||
policy := "not set"
|
||||
if p := m.project.ExecutionWorkspacePolicy; p != nil && p.Enabled {
|
||||
policy = p.DefaultMode
|
||||
if p.Strategy.Type != "" {
|
||||
policy += " · " + p.Strategy.Type
|
||||
}
|
||||
if p.Strategy.Base != "" {
|
||||
policy += " · base " + p.Strategy.Base
|
||||
}
|
||||
}
|
||||
report("worktrees", policy, m.project.ExecutionWorkspacePolicy == nil ||
|
||||
(m.project.ExecutionWorkspacePolicy != nil && m.project.ExecutionWorkspacePolicy.Strategy.Type != "git_worktree"))
|
||||
out = append(out, "")
|
||||
|
||||
// --- what to do, when something is wrong ---
|
||||
if !g.ready() || !g.ignoresPaperclipWorktrees() {
|
||||
out = append(out, mutedStyle2().Render(" WHAT TO DO"))
|
||||
folder := m.folder
|
||||
if !g.ignoresPaperclipWorktrees() {
|
||||
out = append(out, " .paperclip/ is not ignored: a commit would swallow every worktree.")
|
||||
out = append(out, " "+dimStyle().Render("t lazygit · select .gitignore · e to edit · add .paperclip/"))
|
||||
out = append(out, "")
|
||||
}
|
||||
if !g.IsRepo {
|
||||
out = append(out, " This folder is not a repository, so no task can have its own branch.")
|
||||
out = append(out, " "+dimStyle().Render("cd "+folder+" && git init -b main"))
|
||||
out = append(out, " "+dimStyle().Render("then commit something — in lazygit: t, then c"))
|
||||
} else if !g.HasCommit {
|
||||
out = append(out, " This repository has no commit, so there is nothing to cut a worktree from.")
|
||||
out = append(out, " "+dimStyle().Render("t lazygit · space to stage · c to commit"))
|
||||
}
|
||||
out = append(out, "")
|
||||
out = append(out, " Then run "+keyHint("R", "rebuild")+" so every pane is built against the new state.")
|
||||
out = append(out, "")
|
||||
}
|
||||
|
||||
// --- Paperclip's own scan of the task worktrees ---
|
||||
out = append(out, mutedStyle2().Render(" WORKTREES, AS PAPERCLIP SEES THEM"))
|
||||
if len(m.wts) == 0 {
|
||||
out = append(out, dimStyle().Render(" none yet — one is made when a task runs"))
|
||||
}
|
||||
for _, w := range m.wts {
|
||||
state := "clean"
|
||||
if w.Dirty > 0 {
|
||||
state = fmt.Sprintf("%d changed", w.Dirty)
|
||||
}
|
||||
extra := ""
|
||||
if w.Ahead > 0 {
|
||||
extra += fmt.Sprintf(" · %d ahead", w.Ahead)
|
||||
}
|
||||
if w.Behind > 0 {
|
||||
extra += fmt.Sprintf(" · %d behind", w.Behind)
|
||||
}
|
||||
if w.Merged {
|
||||
extra += " · merged into base"
|
||||
}
|
||||
branch := w.Branch
|
||||
if branch == "" {
|
||||
branch = "(no branch)"
|
||||
}
|
||||
if len(branch) > width-34 {
|
||||
branch = branch[:width-35] + "…"
|
||||
}
|
||||
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(branch)+
|
||||
dimStyle().Render(" "+state+extra))
|
||||
if w.Warning != "" {
|
||||
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(w.Warning))
|
||||
}
|
||||
}
|
||||
out = append(out, "")
|
||||
out = append(out, dimStyle().Render(" read from git in this folder, and from Paperclip's scan of each worktree"))
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user