"""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, "" 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, ""