git: a creation gate that never writes history, a git tab with Paperclip's worktree scan, and a warning line in every pane

This commit is contained in:
Sam
2026-11-12 20:38:44 +11:00
parent f7b77f370f
commit c419de5b48
18 changed files with 2223 additions and 82 deletions

Binary file not shown.

View File

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

View File

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