office: worktrees when the folder is a repository, ask before making one, refuse a folder agents cannot see

This commit is contained in:
Sam
2026-11-12 18:09:05 +11:00
parent 55085ba40a
commit f7b77f370f
11 changed files with 1821 additions and 4 deletions

View File

@@ -27,11 +27,13 @@ import shlex
import socket
import subprocess
import sys
import urllib.parse
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "lib"))
import harness as h # noqa: E402
import project as pj # noqa: E402
import repo as rp # noqa: E402
import tuios # noqa: E402
@@ -43,7 +45,8 @@ def parse(argv):
up when someone uses the flag.
"""
opts = {"folder": ".", "folder_given": False, "project": "", "session": "",
"dry": False, "host": "", "no_switch": False, "new": "", "remote": ""}
"dry": False, "host": "", "no_switch": False, "new": "", "remote": "",
"git_init": "--git-init" in argv, "anywhere": "--anywhere" in argv}
positional = []
i = 0
while i < len(argv):
@@ -77,8 +80,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 = subprocess.run(["git", "-C", folder, "rev-parse", "--is-inside-work-tree"],
capture_output=True, text=True, timeout=30).stdout.strip() == "true"
is_repo = rp.is_repo(folder) and rp.has_commit(folder)
body = {
"name": name,
"description": "Adopted from %s by bin/office." % folder,
@@ -95,7 +97,7 @@ def create_project(name, folder, dry):
"enabled": True,
"defaultMode": "isolated_workspace",
"allowIssueOverride": True,
"workspaceStrategy": {"type": "git_worktree", "baseRef": "main",
"workspaceStrategy": {"type": "git_worktree", "baseRef": rp.branch(folder),
"branchTemplate": "{{issue.identifier}}-{{slug}}"},
}
else:
@@ -267,6 +269,9 @@ def run_on_remote(host, folder, opts):
for flag, value in (("--project", opts["project"]), ("--session", opts["session"]), ("--new", opts["new"])):
if value:
inner += [flag, value]
guard_machine(folder, opts)
ready = prepare_git(folder, opts)
if opts["dry"]:
inner.append("--dry")
# "$HOME" must stay unquoted so the other machine's shell expands it; every
@@ -308,6 +313,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>" --anywhere allow it although agents cannot see it
board create any missing pane in this project's session
board --folder <folder> the same, for another project
@@ -341,6 +348,23 @@ EVERY MACHINE, FROM THE TERMINAL
tuios attach <session> on this machine
tuios attach --host nixos-desktop <session> to a session on another machine
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.
The base branch is read from the folder, not assumed to be main.
WHERE THINGS RUN
Panes run on the machine that owns the folder. Agents run on the machine that
serves Paperclip, because Paperclip starts them itself. If those differ, the
panes work and the agents cannot see the files, so --new refuses unless
--anywhere is given.
ON ANOTHER MACHINE
--host copies this folder of commands to the other machine, at
~/.local/share/paperclip-harness/bin/
@@ -366,6 +390,94 @@ MORE
"""
def paperclip_machine():
"""The machine that serves Paperclip, as a name we know, or the address."""
addr = urllib.parse.urlparse(h.API).hostname or ""
if addr in ("127.0.0.1", "localhost", "::1"):
return pj.this_machine()
for name, known in HOSTS.items():
if known == addr:
return name
return addr
def guard_machine(folder, opts):
"""Say plainly when the panes and the agents would be on different machines.
Panes run where the folder is. Agents run where Paperclip is, because
Paperclip starts them itself. A folder on the laptop therefore gives panes
that work and agents that cannot find the files. That is worth stopping for
when a project is being created; an existing project may still be worth
opening just to read it.
"""
if not opts["new"] and not opts["anywhere"]:
# Adopting: a note is enough, and only when it is true.
pass
mine = HOSTS.get(pj.this_machine(), pj.this_machine())
theirs = HOSTS.get(paperclip_machine(), paperclip_machine())
if mine == theirs:
return
text = [
"this folder is on %s, but Paperclip and its agents run on %s" % (pj.this_machine(), paperclip_machine()),
"the panes will open here and will read this folder correctly",
"the agents will not: they start on %s, where %s does not exist" % (paperclip_machine(), folder),
"a task you assign will fail, or work on a different folder",
"move the folder to %s, mount it there, or pass --anywhere to go ahead anyway" % paperclip_machine(),
]
if opts["new"] and not opts["anywhere"]:
raise h.Refused(2, "\n".join(text))
for i, line in enumerate(text):
h.say(("warning: " if i == 0 else " ") + line)
def prepare_git(folder, opts):
"""Make the folder able to hold a worktree per task, and say what it did.
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.
"""
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
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")
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(" 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
def main():
argv = sys.argv[1:]
if not argv or "-h" in argv or "--help" in argv:
@@ -412,11 +524,18 @@ def main():
else:
return run_on_remote(opts["remote"], folder, opts)
guard_machine(folder, opts)
ready = prepare_git(folder, opts)
if opts["dry"]:
if opts["new"]:
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:
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("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")
@@ -457,6 +576,9 @@ def main():
"watching": watching,
}
guard_machine(folder, opts)
ready = prepare_git(folder, opts)
if opts["dry"]:
h.say("would write %s" % pj.path(folder))
h.say("would build pages: shell, tree, reply, log, new")