Files
paperclip-harness/bin/office

887 lines
39 KiB
Python
Executable File

#!/usr/bin/env python3
"""Open a folder as a project: adopt it, remember it, build its pages.
bin/office <folder> [--project <id|urlKey|name>] [--session NAME] [--dry]
bin/office . # the folder you are in
bin/office <folder> --new "Name" # create the Paperclip project too
bin/office --remote <ssh-host> <folder> --project "Name" # the folder is on another machine
Adopting writes <folder>/.harness/ (the project file, check.sh, NOTES.md) and
nothing else. An existing file is never overwritten. With --new, Paperclip gets
a project for the folder, with the per-task worktree policy set; without it,
--project adopts a project that already exists.
A session is created on the daemon of the machine that runs this command, and
the panes run on that machine. A program cannot move your client to the new
session: TUIOS refuses that from a pane. Switch with TUIOS's session switcher,
or `tuios attach --host <machine> <session>`.
The machine is remembered, so the flag is stated once. Running the same command
on a machine that is not the recorded one is refused rather than silently
producing a second copy of the same project.
"""
import json
import os
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
def parse(argv):
"""Read the flags, then take what is left as the folder.
A flag's value is not a folder. The first version of this treated the value
of --remote as the folder, which is exactly the kind of bug that only shows
up when someone uses the flag.
"""
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,
"commit_all": "--commit-all" in argv, "git_init_commits": False,
"client_host": ""}
positional = []
i = 0
while i < len(argv):
a = argv[i]
if a in ("--project", "--session", "--host", "--new", "--remote", "--client-host"):
if i + 1 < len(argv):
# Keys are Python names, so a flag with a hyphen becomes an underscore.
opts[a[2:].replace("-", "_")] = argv[i + 1]
i += 2
continue
i += 1
continue
if a == "--dry":
opts["dry"] = True
elif a == "--no-switch":
opts["no_switch"] = True
elif not a.startswith("-"):
positional.append(a)
i += 1
if positional:
opts["folder"] = positional[0]
opts["folder_given"] = True
return opts
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:
make the project in the browser, then run this command. One command is enough.
"""
# 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 = worktrees and rp.is_repo(folder) and rp.has_commit(folder)
body = {
"name": name,
"description": "Adopted from %s by bin/office." % folder,
"status": "backlog",
"workspace": {
"name": name,
"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:
body["executionWorkspacePolicy"] = {
"enabled": True,
"defaultMode": "isolated_workspace",
"allowIssueOverride": True,
"workspaceStrategy": {"type": "git_worktree", "baseRef": rp.branch(folder),
"branchTemplate": "{{issue.identifier}}-{{slug}}"},
}
else:
h.say(" not a git repository: the project keeps the folder in place,")
h.say(" so a task changes these files directly. No branch, no worktree.")
if dry:
h.say("would create Paperclip project %r for %s" % (name, folder))
return "(dry run)"
try:
created = h.api("POST", "/api/companies/%s/projects" % h.COMPANY, body)
except h.Refused as exc:
# A project with that name may already exist; adopting it is the right
# thing to do then, not a failure.
try:
for p in h.api("GET", "/api/companies/%s/projects" % h.COMPANY):
if p.get("name") == name:
h.say("project: %s (already in Paperclip)" % name)
return p["id"]
except h.Refused:
pass
raise exc
h.say("project: %s created (%s)" % (name, created.get("id")))
return created.get("id")
def ensure_session(session, folder):
"""Make the session exist. It cannot be shown from here, and that is fine.
Moving a person's TUIOS client to another session is refused when the call
comes from a pane — the daemon will not let a program change what the person
is looking at. Creating the session and building its panes works, so the
only step left for the person is switching to it, which TUIOS has a key for.
"""
if session in tuios.sessions():
h.say("session: %s (already there)" % session)
return
params = {"name": session, "cwd": folder, "window": False}
out = subprocess.run(["tuios", "call", "new-session", json.dumps(params)],
capture_output=True, text=True, timeout=60)
if out.returncode != 0 and "session_exists" not in (out.stderr or out.stdout):
raise h.Refused(3, "could not create session %r: %s" % (session, (out.stderr or out.stdout).strip()[:300]))
h.say("session: %s (created; switch to it with TUIOS's session switcher)" % session)
def open_git_session(folder, session, opts):
"""A session with lazygit already open in the folder, on the machine that has it.
The commit has to happen where the files are, and that machine is often not
the one being typed at. A session on that machine is how the person gets
there from TUIOS, which is the whole point of this program: one attach, no
ssh, no cd.
"""
helper = session + "-git"
created = ensure_session_quiet(helper, folder)
# The pane a tool needs a PATH of its own: a pane started this way does not
# inherit an interactive shell's.
prelude = ('export PATH="$HOME/.local/bin:$HOME/.nix-profile/bin:'
'/run/current-system/sw/bin:/etc/profiles/per-user/$USER/bin:$PATH"; ')
tuios.open_pane("git", "Git", folder, prelude + "exec lazygit", helper)
h.say("")
h.say(" A session called %r now holds lazygit in that folder%s." % (helper, "" if created else " (it was already there)"))
h.say(" Attach to it, stage and commit, then come back and repeat the command:")
h.say("")
client = opts.get("client_host") or ""
h.say(" tuios attach%s %s" % (" --host " + client if client else "", helper))
h.say("")
h.say(" When you are done with it: tuios kill-session %s" % helper)
return helper
def ensure_session_quiet(session, folder):
"""ensure_session, without the line about switching: the caller explains."""
if session in tuios.sessions():
return False
params = {"name": session, "cwd": folder, "window": False}
out = subprocess.run(["tuios", "call", "new-session", json.dumps(params)],
capture_output=True, text=True, timeout=60)
if out.returncode != 0 and "session_exists" not in (out.stderr or out.stdout):
raise h.Refused(3, "could not create session %r: %s"
% (session, (out.stderr or out.stdout).strip()[:300]))
return True
def scaffold(folder, name):
"""Write the files this project keeps, and only the ones that are missing.
Anything already there is left exactly as it is: adopting a folder must never
overwrite a person's own notes or scripts.
"""
written = []
harness = os.path.join(folder, ".harness")
os.makedirs(harness, exist_ok=True)
check = os.path.join(harness, "check.sh")
if not os.path.exists(check):
with open(check, "w") as f:
f.write("""#!/bin/sh
# This project's own check, run by bin/merge before and after it puts a task's
# work on the base branch. Exit 0 means "the project still passes".
#
# It does not decide that a task is finished: an agent sets a task's status, and
# the review decides whether the change is good. This is the regression check,
# the same thing a CI pipeline would run.
#
# Leave it as it is while the project has no tests. bin/merge will say there is
# no check and let the review decide. Replace the body when tests exist:
#
# exec go test ./...
# exec make test
echo "this project has no check yet"
exit 0
""")
os.chmod(check, 0o755)
written.append(check)
notes = os.path.join(harness, "NOTES.md")
if not os.path.exists(notes):
with open(notes, "w") as f:
f.write("# %s\n\nWhat this project is, what finished looks like, and anything a person\n"
"picking it up next week needs to know. Notes only: tasks live in Paperclip.\n" % name)
written.append(notes)
return written
# Where the commands live on a machine that is driven over ssh.
REMOTE_BIN = "~/.local/share/paperclip-harness/bin"
HOSTS = {
"nixos-desktop": "192.168.20.13", ".13": "192.168.20.13", "13": "192.168.20.13",
"sam-4screen-desktop": "192.168.20.27", "sam-4screen-desktop-1": "192.168.20.27",
".27": "192.168.20.27", "27": "192.168.20.27",
"sam-thinkpad": "192.168.20.51", ".51": "192.168.20.51", "51": "192.168.20.51",
}
def local_addresses():
"""The addresses and names that mean this machine."""
names = {socket.gethostname(), socket.gethostname().split(".")[0]}
try:
out = subprocess.run(["ip", "-4", "-o", "addr", "show", "scope", "global"],
capture_output=True, text=True, timeout=10).stdout
for line in out.splitlines():
parts = line.split()
if len(parts) >= 4:
names.add(parts[3].split("/")[0])
except Exception:
pass
return names
def resolve_host(name):
"""A host name, or the address ssh can actually reach for it."""
if not name:
return ""
return HOSTS.get(name, name)
def is_this_machine(name):
addr = resolve_host(name)
return addr in local_addresses()
def install_wrapper(addr, remote_bin):
"""Leave a short `office` on the other machine's PATH, pointing at the copy.
The path is written out in full rather than using $HOME, because $HOME is
/root under sudo and the wrapper then looks for a copy that is not there.
"""
home = subprocess.run(["ssh", "-o", "BatchMode=yes", addr, 'printf %s "$HOME"'],
capture_output=True, text=True, timeout=60).stdout.strip()
if not home:
return
# Never replace a wrapper that already works. Pushing from another machine
# would otherwise rewrite the owner's own `office` command to point at the
# copy that machine sent, which is how the canonical machine ended up
# running someone else's older copy.
existing = subprocess.run(
["ssh", "-o", "BatchMode=yes", addr, 'cat "$HOME/.local/bin/office" 2>/dev/null'],
capture_output=True, text=True, timeout=60).stdout
if "paperclip-harness/bin/office" in existing:
return
script = ("#!/bin/sh\n"
"# Written by office on another machine. Runs the copy it keeps here.\n"
"#\n"
"# Run this as your own user, never with sudo: it talks to your TUIOS\n"
"# daemon and to your Paperclip key, and neither exists for root.\n"
'if [ "$(id -u)" = "0" ]; then\n'
' echo "office: run this as your own user, not with sudo." >&2\n'
' exit 2\n'
'fi\n'
'exec "%s/office" "$@"\n' % (home + "/.local/share/paperclip-harness/bin"))
subprocess.run(["ssh", "-o", "BatchMode=yes", addr,
"mkdir -p \"$HOME/.local/bin\" && cat > \"$HOME/.local/bin/office\" && chmod +x \"$HOME/.local/bin/office\""],
input=script, text=True, capture_output=True, timeout=60)
def run_on_remote(host, folder, opts):
"""Adopt a folder that lives on another machine, from where you are sitting.
The session has to be made by the daemon that will hold the panes, and the
panes have to run on the machine that owns the folder. So the work is done
over ssh on that machine, and only the last step — showing the session — is
the client's own business.
"""
addr = resolve_host(host)
probe = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", addr, "true"],
capture_output=True, text=True, timeout=30)
if probe.returncode != 0:
raise h.Refused(3, "cannot log in to %s (%s). Set up a key first: ssh-copy-id %s"
% (host, addr, addr))
# The other machine needs the commands. They are copied to one fixed place
# there rather than assumed to be in the same folder: a machine that has
# never seen this repository still gets a working `office`. Copying every
# time is what keeps the two in step, and it is one small folder.
here = os.path.dirname(os.path.realpath(__file__))
remote_bin = REMOTE_BIN
# rsync will not make two levels of directory, so make the parent first.
subprocess.run(["ssh", "-o", "BatchMode=yes", addr,
"mkdir -p \"$HOME/.local/share/paperclip-harness\""],
capture_output=True, text=True, timeout=60)
copy = subprocess.run(
["rsync", "-a", "--delete", "--exclude", "__pycache__", "--exclude", "*.pyc",
here + "/", "%s:%s/" % (addr, remote_bin)],
capture_output=True, text=True, timeout=300)
if copy.returncode != 0:
raise h.Refused(3, "could not copy the commands to %s: %s"
% (host, (copy.stderr or copy.stdout).strip()[:300]))
install_wrapper(addr, remote_bin)
inner = [folder]
for flag, value in (("--project", opts["project"]), ("--session", opts["session"]), ("--new", opts["new"])):
if value:
inner += [flag, value]
for flag in ("--git-init", "--commit-all", "--anywhere"):
if opts.get(flag[2:].replace("-", "_")):
inner.append(flag)
# So the instructions printed on the other machine say how to reach it from
# where the person is sitting.
inner += ["--client-host", host]
if opts["dry"]:
inner.append("--dry")
# "$HOME" must stay unquoted so the other machine's shell expands it; every
# argument after it is quoted, so a folder with a space still arrives whole.
cmd = 'exec "$HOME/.local/share/paperclip-harness/bin/office" ' + \
" ".join(shlex.quote(a) for a in inner)
h.say("on %s: %s" % (host, cmd))
# A question is asked on the other machine when the folder cannot hold a
# worktree, so give it a terminal when this one has one: without it, ssh
# hands it a pipe and it has to refuse instead of asking.
ssh_args = ["ssh", "-o", "StrictHostKeyChecking=accept-new"]
interactive = sys.stdin.isatty()
if interactive:
ssh_args.append("-t")
out = subprocess.run(ssh_args + [addr, cmd], timeout=600) if interactive else \
subprocess.run(ssh_args + [addr, cmd], capture_output=True, text=True, timeout=600)
if not interactive:
sys.stdout.write(out.stdout)
if out.returncode != 0:
detail = ""
if not interactive:
detail = (out.stderr or out.stdout or "").strip()[:300]
raise h.Refused(3, "the other machine stopped with exit %d%s"
% (out.returncode, ": " + detail if detail else ""))
return 0
USAGE = """office — everything for one project, under one word
office <folder> … open a folder as a project
office help this page
office board … build or repair the panes
office reset <folder> … clear panes and worktrees, keep the history
office delete <folder> … remove sessions, the Paperclip project, .git, dot files
office merge <task> … rebase, check, review, merge one task
office review <task> … run ocr over one task's diff
office triage <task> … ask Mouse-1 one question about a task
Every one of these is also on your PATH under its own name (board, merge,
review, triage, project-reset, project-delete). Both spellings do the same
thing; office is the one to remember.
THE RULE
The folder and the session live on the same machine. If you are typing inside
the session, on the machine that owns the folder, it is just: office <folder>
Use --host only when the folder is on a different machine from your shell.
OPENING A FOLDER
office <folder> --project "<Name>" adopt a project Paperclip already has
office <folder> --new "<Name>" create the Paperclip project as well
office . adopt the folder you are standing in
office <folder> --session <name> name the session instead of the folder
office <folder> … --host <machine> the folder is on that machine
office <folder> … --dry say what would happen, write nothing
What it does, in order: reads the folder; runs locally if the named machine is
this one; otherwise checks the login and runs the same command there; adopts or
creates the Paperclip project; writes what is missing in <folder>/.harness/ and
never overwrites; records the project in ~/.config/paperclip-harness/projects.toml;
creates the session on that machine's daemon; builds eight panes.
PAGES: office board [--folder <f>] [--replace] [--reset]
board create any pane that is missing, close nothing
board --replace close this project's panes, then build all eight again
board --reset close every pane in the session, wait, then build
board --folder <folder> do it for another project
Inside a pane, R rebuilds everything and r re-reads Paperclip. After a reboot
TUIOS restores a session with a bare shell in each pane and keeps the pane
names, so run board once: it now checks what each pane is really running and
replaces any pane that is only a prompt.
STARTING OVER
office reset <folder> clear the panes and the worktrees; keeps the
folder, .harness, the git history and every
Paperclip record
office reset <folder> --dry-run say what would go
office reset <folder> --yes do it
office reset <folder> --yes --delete-folder remove the folder too
office delete <folder> remove the TUIOS sessions, every Paperclip
project that claims the folder, the registry
lines, and .git / .gitignore / .harness /
.paperclip / .pi. Keeps ordinary files.
office delete <folder> --yes do it
office delete <folder> --yes --delete-folder remove the folder as well
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, 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/, and stop.
Nothing is staged. Ignore first, then stage, then commit, in lazygit.
N create the project in place (local_path): a task edits the folder
directly, there is no diff and nothing to merge.
Y opens a session with lazygit already in the folder, on the machine that owns
it, and tells you how to attach. Ignore with i, stage all with a, commit with c.
Then run the same office command again and it creates the project.
.paperclip/ is written into .gitignore every time and is not optional: it is
where Paperclip puts the worktrees, inside the project folder.
Ignoring a file does NOT untrack one that is already committed: office warns
and gives you the git rm --cached line when it finds that.
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 office --new refuses unless
--anywhere is given.
MACHINES (name, or the address ssh reaches)
nixos-desktop .13 192.168.20.13 hosts Paperclip, the daemon, the main session
sam-4screen-desktop .27 192.168.20.27 your working desktop
sam-thinkpad .51 192.168.20.51 the laptop
FROM A TERMINAL, ON ANY MACHINE
a folder on .13, typed on .13:
office ~/paseo/projects/test-alpha --project "test-alpha"
a folder on .13, typed on .27:
office /home/sam/TEST-BETA --new "TEST-BETA" --host nixos-desktop
a folder on .27, typed on .27:
office ~/src/thing --new "thing"
then attach; nothing may move your client from inside a pane:
tuios attach <session>
tuios attach --host nixos-desktop <session>
--host copies these commands to the other machine at
~/.local/share/paperclip-harness/bin/ and leaves ~/.local/bin/office there, so
a machine that has never seen this repository still works.
MERGE AND REVIEW
office merge <task> rebase onto the base, run the project's check, run ocr,
merge, re-run the check, roll back if it fails. It asks
for confirmation before touching anything.
office review <task> ocr over one task's diff; --post records the verdict
office triage <task> one four-option Mouse-1 question, posted as a comment
KEYS INSIDE THE PANES
( ) change tab tab change panel enter open / choose
r re-read Paperclip R rebuild all panes ? list the keys
t tools (lazygit, yazi, nvim, hunk, fzf, ocr, shell)
g lazygit, straight away, in the project folder
h j k l z n and digits belong to TUIOS and never reach the program.
THE PROJECT PANE
overview · git · facts · costs · settings · projects
The git tab reads the folder and Paperclip together: repository, branch,
commits, remote, what is staged, what .gitignore holds, the worktree policy,
and Paperclip's own scan of every task worktree (dirty, ahead, behind, merged).
WHILE A FOLDER CANNOT HOLD A WORKTREE
Every pane carries one line at the top, naming what is wrong and where to look.
It is read when the pane starts; fix the folder and press R to clear it.
MORE
docs/STARTUP.md these commands, the rule, the rebuild levels
docs/WORKSPACES.md what each of the eight panes is for
docs/RUNS-AND-MERGE.md how a task becomes merged work
docs/CLEAN-TESTS.md the two end-to-end tests and what they measured
"""
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 repeat_command(folder, opts):
"""The same command again, spelled out, with the arguments that were used."""
parts = ["office", folder]
if opts.get("new"):
parts += ["--new", '"%s"' % opts["new"]]
elif opts.get("project"):
parts += ["--project", '"%s"' % opts["project"]]
if opts.get("session"):
parts += ["--session", opts["session"]]
if opts.get("host"):
parts += ["--host", opts["host"]]
return " ".join(parts)
def prepare_git(folder, opts, session):
"""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 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):
note_ignore(folder, opts)
warn_tracked_but_ignored(folder)
return "ready"
if opts["dry"]:
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.")
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'")
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 now, then three steps:")
h.say("")
h.say(" 1. PUT INTO .gitignore EVERY FILE YOU DO NOT WANT COMMITTED.")
h.say(" A file there is invisible to the agent, which is usually what")
h.say(" you want for evidence folders, notes with credentials, and")
h.say(" .paperclip/, which is written there for you.")
h.say(" 2. commit what is left, in lazygit: stage with space, commit with c.")
h.say(" 3. run this same command again to create the project with worktrees:")
h.say("")
h.say(" %s" % repeat_command(folder, opts))
h.say("")
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 opts["commit_all"] or (opts["git_init"] and opts["git_init_commits"]):
if not rp.stage_all(folder):
raise h.Refused(2, "git add failed in %s" % folder)
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(" NOTHING IS STAGED AND NOTHING IS COMMITTED YET, which is the right order:")
h.say(" decide what to leave out first, then stage what is left.")
open_git_session(folder, session, opts)
h.say("")
h.say(" In lazygit:")
h.say("")
h.say(" i put the selected file or folder into .gitignore")
h.say(" a stage everything that is left after the ignores")
h.say(" c commit e edit a file ? every key")
h.say("")
h.say(" Ignore first, stage second: a file that is already staged stays staged")
h.say(" even after you ignore it, and would still be committed.")
h.say("")
h.say(" Then, after committing, repeat this command to create the project:")
h.say("")
h.say(" %s" % repeat_command(folder, opts))
return "staged"
def warn_tracked_but_ignored(folder):
"""Ignoring a file does not remove it from the repository.
A path already in a commit stays there until it is removed from the index.
Saying so is the difference between a rule and a misunderstanding.
"""
# -c is required: without it git refuses, and the check silently passed
# while four files sat in the commit that .gitignore said to ignore.
out = rp.git(folder, "ls-files", "-c", "-i", "--exclude-standard")
if out.returncode != 0 or not out.stdout.strip():
return
names = [n for n in out.stdout.strip().split("\n") if n][:6]
h.say(" warning: .gitignore now names %d file(s) that are already in the" % len(names))
h.say(" repository. Ignoring does not untrack them:")
for n in names:
h.say(" %s" % n)
h.say(" to remove them, commit the removal:")
h.say(" git rm --cached -r <the paths above> && git commit -m 'stop tracking them'")
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.")
# The commands that have their own script next to this one. `office board` and
# `board` must do the same thing: a person should only have to remember office.
SIBLINGS = {
"board": "board",
"pages": "board",
"build": "board",
"reset": "project-reset",
"project-reset": "project-reset",
"delete": "project-delete",
"project-delete": "project-delete",
"forget": "project-delete",
"merge": "merge",
"review": "review",
"triage": "triage",
}
def forward(argv):
"""Run a sibling command with the rest of the arguments, or return None."""
if not argv:
return None
name = SIBLINGS.get(argv[0])
if name is None:
return None
script = os.path.join(HERE, name)
if not os.path.isfile(script):
raise h.Refused(2, "no %s next to office; the repository looks incomplete" % name)
# exec, not a subprocess: this program is a router for this one call and has
# nothing left to do, so the sibling should be the process the person sees.
os.execv(sys.executable, [sys.executable, script] + argv[1:])
def main():
if os.geteuid() == 0:
h.say("office: run this as your own user, not with sudo.")
h.say(" it reads your Paperclip key and asks your TUIOS daemon to make panes;")
h.say(" neither of those exists for root, and files it wrote would belong to root.")
return 2
argv = sys.argv[1:]
if argv and argv[0] in ("help", "-h", "--help"):
h.say(USAGE.strip())
return 0
forward(argv)
if not argv or "-h" in argv or "--help" in argv:
h.say(USAGE.strip())
return 0
opts = parse(argv)
if not opts["folder_given"]:
# "." is a real folder: `office . --project <name>` adopts the folder you
# are standing in. Only a missing argument is an error.
raise h.Refused(2, "give the folder: office <folder> --project \"<Name>\" (office --help)")
folder = os.path.abspath(os.path.expanduser(opts["folder"]))
# The folder is only checked here when it is this machine's folder. A folder
# on another machine is checked there: asking this machine about it says "no
# folder", which is true and useless.
wanted = opts["host"] or opts["remote"]
if not (wanted and not is_this_machine(wanted)) and not os.path.isdir(folder):
raise h.Refused(2, "no folder at %s" % folder)
host = opts["host"] or pj.this_machine()
if opts["host"] and not is_this_machine(opts["host"]):
return run_on_remote(opts["host"], folder, opts)
if opts["host"]:
h.say("host: %s (this machine)" % opts["host"])
# An adopted project already knows its session. Only a new one needs a
# default, and a default that names a session nobody made is worse than an
# error, so the session is checked before anything is written.
existing = {}
if pj.exists(folder):
existing = pj.load(folder)
session = (
opts["session"]
or existing.get("session")
or os.path.basename(folder)
)
# The session is created if it does not exist and the client is moved to it,
# so one command takes a person from a folder to a working layout. TUIOS has
# the verb for exactly this; asking the person to run `tuios new` first was
# the step everyone forgot.
if opts["remote"]:
if is_this_machine(opts["remote"]):
h.say("host: %s (this machine)" % opts["remote"])
opts["remote"] = ""
else:
return run_on_remote(opts["remote"], folder, opts)
# Only now: a folder on another machine has already been handed over, and
# the machine that owns it does these two checks itself.
guard_machine(folder, opts)
ready = prepare_git(folder, opts, session)
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 == "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")
return 0
if opts["host"] and opts["host"] not in (pj.this_machine(), pj.this_machine().split(".")[0]):
# 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 your files, then run this again.")
return 0
if opts["new"]:
ref = create_project(opts["new"], folder, opts["dry"], ready == "ready")
if not ref:
return 1
opts["project"] = ref
ref = opts["project"]
if not ref and pj.exists(folder):
ref = pj.load(folder)["paperclip"]["project"]
if not ref:
raise h.Refused(
2,
"which Paperclip project is %s? pass --project <id|urlKey|name>" % folder,
)
proj = pj.resolve_project(ref)
h.say("project: %s (%s)" % (proj["name"], proj["id"]))
h.say("folder: %s" % folder)
h.say("session: %s" % session)
h.say("machine: %s" % host)
tasks = pj.active_tasks(proj["id"])
watching = next((t["identifier"] for t in tasks if t["status"] not in ("done", "cancelled")),
tasks[0]["identifier"] if tasks else "")
cfg = {
"session": session,
"paperclip": {"company": h.COMPANY, "project": proj["id"]},
"machine": {"host": host, "folder": folder},
"watching": watching,
}
if opts["dry"]:
h.say("would write %s" % pj.path(folder))
h.say("would build pages: shell, tree, reply, log, new")
return 0
pj.save(folder, cfg)
h.say("wrote %s" % pj.path(folder))
if not opts["no_switch"]:
ensure_session(session, folder)
pj.register(cfg, proj["name"])
h.say("recorded in %s" % pj.REGISTRY)
written = scaffold(folder, proj["name"])
for path in written:
h.say("wrote %s" % path)
# The pages are built by this machine, from this folder, in this session.
code = os.spawnv(
os.P_WAIT,
sys.executable,
[sys.executable, os.path.join(HERE, "board"), "--folder", folder],
)
if code == 0:
# Only now is there a session worth attaching to. Promising one earlier
# is how a person ends up trying to attach to a session that was never
# created, because the project was still waiting for its first commit.
client = opts.get("client_host") or ""
h.say("")
h.say("switch to it: tuios attach%s %s"
% (" --host " + client if client else "", session))
return code
if __name__ == "__main__":
h.main_guard(main)