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:
BIN
bin/harness
BIN
bin/harness
Binary file not shown.
@@ -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, ""
|
||||
|
||||
172
bin/office
172
bin/office
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user