611 lines
26 KiB
Python
Executable File
611 lines
26 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}
|
|
positional = []
|
|
i = 0
|
|
while i < len(argv):
|
|
a = argv[i]
|
|
if a in ("--project", "--session", "--host", "--new", "--remote"):
|
|
if i + 1 < len(argv):
|
|
opts[a[2:]] = 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):
|
|
"""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 = 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,
|
|
},
|
|
}
|
|
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 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."""
|
|
script = ("#!/bin/sh\n"
|
|
"# Written by office on another machine. Runs the copy it keeps here.\n"
|
|
'exec "$HOME/.local/share/paperclip-harness/bin/office" "$@"\n')
|
|
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, "--no-switch"]
|
|
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
|
|
# 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))
|
|
out = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", addr, cmd],
|
|
capture_output=True, text=True, timeout=600)
|
|
sys.stdout.write(out.stdout)
|
|
if out.returncode != 0:
|
|
raise h.Refused(3, "the other machine refused: %s" % (out.stderr or out.stdout).strip()[:300])
|
|
h.say("")
|
|
h.say("switch to it: tuios attach --host %s %s" % (host, opts["session"] or os.path.basename(folder)))
|
|
return 0
|
|
|
|
|
|
USAGE = """office — open a folder as a project, and say what else can be run
|
|
|
|
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.
|
|
|
|
WHAT IT DOES
|
|
1. reads the folder
|
|
2. if a machine is named, checks whether that is this machine, and runs locally if so
|
|
3. if it really is another machine, checks the login and runs the same command there
|
|
4. adopts the Paperclip project, or creates it with --new
|
|
5. writes what is missing in <folder>/.harness/ and never overwrites
|
|
6. records the project in ~/.config/paperclip-harness/projects.toml
|
|
7. creates the session on that machine's daemon, then builds eight panes
|
|
|
|
COMMANDS
|
|
|
|
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> --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
|
|
board --replace close this project's panes, then build all eight again
|
|
board --reset close every pane in the session, then build
|
|
project-reset <folder> forget the session and start this project again
|
|
merge <task> rebase, check, review and merge one task (asks first)
|
|
review <task> [--post] run ocr over one task's diff, and record the verdict
|
|
triage <task> [--ask next|failure] ask Mouse-1 one question about a task
|
|
|
|
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
|
|
|
|
EVERY MACHINE, FROM THE TERMINAL
|
|
|
|
On .13, a folder that is on .13:
|
|
office ~/paseo/projects/test-alpha --project "test-alpha"
|
|
|
|
On .27, adopting a folder that lives on .13:
|
|
office /home/sam/paseo/projects/email_agent --project "Email Agent" --host nixos-desktop
|
|
|
|
On .27, a folder that is on .27 (stays local, no --host):
|
|
office ~/src/thing --new "thing"
|
|
|
|
On .51, the same folder on .51:
|
|
office ~/src/thing --project "thing" --host sam-thinkpad
|
|
|
|
Then attach. Nothing may move your client from inside a pane, so the last step is yours:
|
|
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/
|
|
and leaves a short ~/.local/bin/office there, so it can be run from that
|
|
machine too. Nothing has to be installed by hand. On .27 and .51 ~/.local/bin
|
|
is not on PATH yet, so there you call it by its full path:
|
|
~/.local/share/paperclip-harness/bin/office <folder>
|
|
|
|
AFTER A REBOOT
|
|
TUIOS restores a session with a fresh shell in every pane and keeps the pane
|
|
names. Run board once for that project: it checks what each pane is actually
|
|
running and replaces any pane that is only a prompt.
|
|
|
|
KEYS INSIDE THE PANES
|
|
( ) change tab tab change panel enter open / choose
|
|
r re-read Paperclip R rebuild all panes ? list the keys
|
|
h j k l z n and digits belong to TUIOS and never reach the program.
|
|
|
|
MORE
|
|
docs/STARTUP.md these commands, the rule, and the three rebuild levels
|
|
docs/WORKSPACES.md what each of the eight panes is for
|
|
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 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:
|
|
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)
|
|
|
|
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")
|
|
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 opts["new"]:
|
|
ref = create_project(opts["new"], folder, opts["dry"])
|
|
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,
|
|
}
|
|
|
|
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")
|
|
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],
|
|
)
|
|
return code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
h.main_guard(main)
|