$ cat node-template.py

W

Working branch

// Create the plan's working branch in a GitHub repository, off a base branch, and hand every downstream node the repository to work in. Lanes branch off it and open their pull requests back into it; the Codebase map reads it. Re-running reuses an existing branch and never moves it. Needs a GitHub connection that can write to the repository.

Process
Integration
#github#branch#repository#lane#vibe
template.py
1"""Working branch — the plan's branch, created first, used by everything after.23A vibe-coding plan works on ONE branch off the base: every Lane branches off it4and opens its pull request back into it (the Lane passes its `repo.base_branch`5to both `git clone --branch` and `gh pr create --base`), and the Codebase map6reads the repository at it. When the lanes are merged, the working branch ships7to the base as one pull request. That is why it is created before anything8else: every downstream node needs a concrete repository and ref.910## It never moves a branch1112If the working branch already exists it is REUSED, at whatever commit it is on.13It is never reset to the base and never force-updated — it may already hold14merged lane work, and a rerun of this node must not be able to erase that.15This is the same rule the lanes follow (D18: no force-push, ever).1617## Credentials1819`GITHUB_TOKEN` comes from the user's GitHub connection, injected by the20executor for every canvas node the user runs, and is reached ONLY through21`gais.connectors.github` — the sandbox blocks network imports in template22code, and the connector keeps the token out of every error message.23"""2425import json26import re27import sys28import traceback2930from gais import connectors31from gais._errors import ConfigError, RemoteError3233# Git's own rules for a ref name, reduced to what a working branch needs.34# Validated here rather than left to GitHub so the error says what is wrong35# with the NAME instead of an opaque 422.36BRANCH_OK = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")373839class BranchError(Exception):40    """A failure the user can act on. The message is shown as-is."""414243def branch_problem(branch: str, base: str | None = None) -> str | None:44    if not branch:45        return "The working branch name is empty."46    if not BRANCH_OK.match(branch):47        return f"'{branch}' is not a valid branch name: use letters, digits, '.', '_', '-' and '/'."48    if ".." in branch or "//" in branch or branch.endswith("/") or branch.endswith(".lock"):49        return f"'{branch}' is not a valid branch name."50    if base and branch == base:51        return (f"The working branch cannot be the base branch '{base}' itself — the plan's work "52                "needs its own branch so it can ship as one pull request.")53    return None545556def call(method: str, path: str, body: dict | None = None):57    """(status, body) — the only GitHub call, so tests replace it.5859    A non-2xx is RETURNED as its status rather than raised: this node's logic60    branches on 404 / 422 / 403, and each means something different to say.61    """62    try:63        return 200, connectors.github.request(method, path, json_body=body)64    except RemoteError as e:65        return e.http_status, {}666768def ref_path(owner, repo, branch):69    # No URL-encoding needed: `branch_problem` admits only letters, digits,70    # '.', '_', '-' and '/', all safe in a path — and GitHub's ref API wants71    # the slashes of `feature/x` literal.72    return f"/repos/{owner}/{repo}/git/ref/heads/{branch}"737475def ensure_branch(owner: str, repo: str, base: str, branch: str, call=call) -> dict:76    """Create `branch` off `base`, or reuse it. Returns the node's outputs."""77    status, info = call("GET", f"/repos/{owner}/{repo}")78    if status == 404:79        raise BranchError(f"Repository {owner}/{repo} was not found, or your GitHub connection cannot see it.")80    if status >= 400:81        raise BranchError(f"GitHub answered {status} for {owner}/{repo}.")82    base = base or info.get("default_branch") or ""83    if not base:84        raise BranchError(f"{owner}/{repo} has no default branch; set the base branch explicitly.")85    if not BRANCH_OK.match(base) or ".." in base:86        raise BranchError(f"'{base}' is not a base branch this node can use.")8788    problem = branch_problem(branch, base)89    if problem:90        raise BranchError(problem)9192    status, ref = call("GET", ref_path(owner, repo, base))93    if status == 404:94        raise BranchError(f"The base branch '{base}' does not exist in {owner}/{repo}.")95    if status >= 400:96        raise BranchError(f"GitHub answered {status} reading the base branch '{base}'.")97    base_sha = (ref.get("object") or {}).get("sha", "")9899    status, created = call("POST", f"/repos/{owner}/{repo}/git/refs",100                          {"ref": f"refs/heads/{branch}", "sha": base_sha})101    if status == 200:102        head_sha, was_created = (created.get("object") or {}).get("sha", base_sha), True103    elif status == 422:104        # Already exists. Reuse it exactly where it is — never reset it to the105        # base: it may hold lane work that has already been merged into it.106        status, existing = call("GET", ref_path(owner, repo, branch))107        if status >= 400:108            raise BranchError(f"'{branch}' exists but could not be read (GitHub answered {status}).")109        head_sha, was_created = (existing.get("object") or {}).get("sha", ""), False110    elif status in (403, 404):111        raise BranchError(f"Your GitHub connection cannot create branches in {owner}/{repo}.")112    else:113        raise BranchError(f"GitHub answered {status} creating '{branch}'.")114115    return {116        "repo": {"owner": owner, "repo": repo, "base_branch": branch},117        "branch": branch,118        "base_branch": base,119        "head_sha": head_sha,120        "created": was_created,121        "url": f"https://github.com/{owner}/{repo}/tree/{branch}",122    }123124125def main():126    try:127        envelope = json.loads(sys.stdin.read() or "{}")128        inputs = envelope.get("inputs", {}) if isinstance(envelope, dict) else {}129        owner = (inputs.get("owner") or "").strip()130        repo = (inputs.get("repo") or "").strip()131        base = (inputs.get("base_branch") or "").strip()132        branch = (inputs.get("branch") or "").strip()133        if not owner or not repo:134            raise BranchError("Set the repository owner and name.")135        connectors.github.require()136        out = ensure_branch(owner, repo, base, branch)137        print(f"[working-branch] {owner}/{repo} {out['branch']} off {out['base_branch']} "138              f"{'created' if out['created'] else 'reused'} at {out['head_sha'][:8]}", file=sys.stderr)139        json.dump(out, sys.stdout)140    except (BranchError, ConfigError) as e:141        # ConfigError is "no GitHub connection reached this node" — already142        # phrased for a user by the connector, naming where to add one.143        print(json.dumps({"error": str(e), "errorType": type(e).__name__}), file=sys.stderr)144        sys.exit(1)145    except Exception as e:146        print(json.dumps({"error": str(e), "errorType": type(e).__name__,147                          "traceback": traceback.format_exc()}), file=sys.stderr)148        sys.exit(1)149150151if __name__ == "__main__":152    main()153

$ git log --oneline

v1.0.0
HEAD
2026-10-14

First marketplace publish: vibe-coding lanes and the gstack plan flow (PR #1313).