office: worktrees when the folder is a repository, ask before making one, refuse a folder agents cannot see
This commit is contained in:
69
bin/lib/repo.py
Normal file
69
bin/lib/repo.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""What a folder needs to be before Paperclip can give a task its own worktree.
|
||||
|
||||
A worktree is a git feature: Paperclip makes a directory and a branch per task
|
||||
from the project's repository. A folder that is not a repository cannot have
|
||||
one, and a repository with no commit has no branch to cut from. Both cases are
|
||||
checked here, and neither is repaired without being asked.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
|
||||
def git(folder, *args, timeout=60):
|
||||
return subprocess.run(["git", "-C", folder] + list(args),
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def is_repo(folder):
|
||||
if not os.path.isdir(folder):
|
||||
return False
|
||||
return git(folder, "rev-parse", "--is-inside-work-tree").stdout.strip() == "true"
|
||||
|
||||
|
||||
def has_commit(folder):
|
||||
return git(folder, "rev-parse", "--verify", "HEAD").returncode == 0
|
||||
|
||||
|
||||
def branch(folder):
|
||||
"""The branch to cut task branches from: what is checked out, else main, else master."""
|
||||
out = git(folder, "symbolic-ref", "--short", "HEAD").stdout.strip()
|
||||
if out:
|
||||
return out
|
||||
for name in ("main", "master"):
|
||||
if git(folder, "rev-parse", "--verify", name).returncode == 0:
|
||||
return name
|
||||
return "main"
|
||||
|
||||
|
||||
def untracked_and_modified(folder):
|
||||
out = git(folder, "status", "--porcelain").stdout.splitlines()
|
||||
return len([ln for ln in out if ln.strip()])
|
||||
|
||||
|
||||
def has_ignored(folder):
|
||||
return os.path.isfile(os.path.join(folder, ".gitignore"))
|
||||
|
||||
|
||||
def make_repo(folder, quiet=False):
|
||||
"""git init, add everything, and one commit. Returns (ok, message)."""
|
||||
say = (lambda *_: None) if quiet else print
|
||||
steps = [
|
||||
("init", ["init", "-b", "main"]),
|
||||
]
|
||||
for label, args in steps:
|
||||
r = git(folder, *args)
|
||||
if r.returncode != 0:
|
||||
return False, "git %s failed: %s" % (label, (r.stderr or r.stdout).strip()[:200])
|
||||
if git(folder, "add", "-A").returncode != 0:
|
||||
return False, "git add failed"
|
||||
r = git(folder, "-c", "user.name=Paperclip Harness",
|
||||
"-c", "user.email=harness@localhost",
|
||||
"commit", "-q", "-m", "Initial commit, before any Paperclip task")
|
||||
if r.returncode != 0:
|
||||
detail = (r.stderr or r.stdout).strip()
|
||||
if "nothing to commit" in detail:
|
||||
return False, "the folder has no files to commit"
|
||||
return False, "git commit failed: %s" % detail[:200]
|
||||
say(" made a repository on branch %s, one commit" % branch(folder))
|
||||
return True, ""
|
||||
130
bin/office
130
bin/office
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user