Skip to content

Repo round trip: Open, Create, Commit, Pull (app-repos work item 3) - #622

Merged
Evanfeenstra merged 6 commits into
mainfrom
feature/repo-round-trip
Nov 11, 2026
Merged

Evanfeenstra merged 6 commits into
mainfrom
feature/repo-round-trip

Conversation

@Evanfeenstra

@Evanfeenstra Evanfeenstra commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Work item 3 of plans/app-repos.md: the round trip. A strut works ON a git repository that is a package, the way an editor works on a checkout. The installed items (acme/moonlander/score) are the ones being edited, so an export never makes a second copy of what the workspace names bare. Git merges; strut writes files and applies trees.

Builds on #621 (merged): its git+ resolver pieces (fetchCache, exportGitTree, parseGitRef) and upstream.commit.

What it adds

  • Open repo (POST /packages/repos { url, ref?, path?, tokenSecret? }): the repo is fetched into git/checkout's bare cache, and a KEPT working copy is made at <dataDir>/packages/work/<publisher>/<name>/. The branch's commit is installed through a third door, repo, and the repo goes on file in <dataDir>/packages/repos.json with the token secret's NAME only.
  • The repo door (src/packages/install.ts): the commit is the truth, since the workspace's edits are always committed into it first.
    • Every item is made what the commit holds, a fork too.
    • A version whose content is the commit's already is stamped with it in place. That is a new PublishOptions.stamp in both workspace stores, with a conformance case.
    • What the commit dropped is retired.
    • A Concept page made here under a page's name is taken as the package's (ConceptSet.adopt), but only for a repo already open or Create repo's own open. A first Open refuses it, as an ordinary install does.
  • Commit (POST /packages/repos/:publisher/:name/commit { message, branch?, pr? }):
    • the workspace's edits are written into the working copy with the new packageFiles(deps, { under: pkg }), then shown as a diff (GET …/diff);
    • committed with the actor's identity and pushed, to a new branch plus a PR (github/create-pr's openPullRequest) or straight to the followed branch when asked;
    • then re-stamped, so nothing is a fork.
  • Pull (POST …/pull):
    • edits are committed locally first;
    • then git merge. A conflict names the files (409) and installs nothing; the person resolves it in the working copy and pulls again;
    • otherwise the merged tree is applied.
  • Create repo (POST /packages/repos/create): the package publisher's picks are built, pushed as the first commit of an EMPTY remote, then opened. The bare originals are offered for retirement (…/retire), never removed silently.
  • New items in an opened repo: a package opened as a repo takes NEW workflows, steps and apps under its prefix, from any door (the builder's tools, POST /workflows, POST /steps, POST /apps). Commit writes them as new files, and the repo door then stamps them as the package's. This is WorkspaceStore.setOpenPackages, which createStrut sets from repos.json. Every other installed package keeps marketplace §3's name rule.
  • package/commit: the agent's door. It runs only as an accepted proposal, checked against the record on disk. It is granted and in approve: beside package/install in the job, builder and orchestrator seeds.
  • UI: a Repos section in the Packages tab (web/src/components/PackageRepos.tsx), with Open repo and, per repo, its branch, the commit installed, the items new and edited here, Commit (with the diff), Pull (with any conflict) and Close. Create repo sits on a package's page beside Publish.
  • Docs: specs/API.md §16 ("Working on a repository"), AGENTS.md (packages row, package/ row), and plans/app-repos.md (item 3 BUILT, item 2 merged).

Choices beyond the plan

  • A repo's id is its package (one repo per package). The routes take :publisher/:name.
  • A repo is opened on a branch: a tag or a commit cannot be followed or pushed to.
  • The working copy's branch is its own, repo/<publisher>/<name>, tracking the followed branch. No other worktree of the shared cache ever holds it.
  • A Commit writes only the EDITS: items whose active version the repo door did not write at the commit on file, plus deletions. Writing every item would undo what git merged whenever HEAD is past the installed commit.
    • strut.yaml, serve.yaml, graph/, the README and other files stay as the repo has them.
    • Claims travel with an edited step.
  • A new item is named under the package (acme/moonlander/ghost). Inside a package a bare name means its own item first, so a bare new name is outside the package. The builder learns this two ways:
    • where the rule still holds, the refusal says to open the package's repo;
    • the App Concept page gains a short "An app in a repo" section.
      A file added to the working copy also installs at the next Pull.
  • Concepts are included. A new page under one of the package's pages is the package's at the next Commit (the repo door adopts it). It shows in the diff, not in the row's "new" list, since finding it walks the tree.
  • One name, one door.
    • A first Open refuses workflows, steps and apps made here under the package's name, and a Concept page made here with one of its pages' names, as an ordinary install does. Only an open repo's apply, a reopen, and Create repo's own open take them.
    • While a repo is open, no other door writes or removes its package: a person's install of the same name, the distro's (a subscription, a served box) and an uninstall are 409 package_repo_owned. Close the repo and uninstall it to install that name from elsewhere.
    • Create repo refuses a name installed here, or one with items under it, before it pushes anything.
    • Bare items made here (score beside acme/moon/score) and a package of the same name published from this strut's catalog are left alone: neither is the repo's.
  • Closing a repo puts the rule back. An item added and not committed stays a local item: it runs and can be edited, but takes no new name beside it. The first Commit after the repo is opened again writes it.
  • The re-stamp rewrites the known version's door, actor and upstream in place, with no new version. It runs after every other write of the install, since a stamp is not taken back.
  • Commit defaults to a new branch strut/<name>-<sha> with a PR. That is GitHub only; elsewhere the branch is pushed and prSkipped says why. It pushes straight only when branch names the followed branch, and it never forces.
  • A commit's author is the token's GitHub account on github.com (as git/push); elsewhere the actor's id at strut@strut.invalid.
  • A pull's conflict is kept on the record (merge). Until the merged tree is applied, nothing more is written from the workspace and Commit waits. Edits made in the workspace meanwhile are not carried. Pull concludes a merge whose files the person resolved and git added.
  • package/commit is approver: "admin": the HTTP door is full scope, and a push may use the deployment's token when the person has none of their own.
  • Close (DELETE /packages/repos/:publisher/:name), beyond the plan's list, forgets the repo and its working copy. The installed items stay.
  • The diff preview writes the edits into the working copy and stages them, without committing.

Tests

src/packages/repos.test.ts runs against local bare remotes (git init --bare, file://) over the HTTP routes:

  • Commit straight to the branch. Open, edit through meta/edit-workflow, Commit straight to main. The remote's file changed, and the item is no fork: stamped in place, no third version.
  • Conflict. A conflicting commit on the remote, then Pull: 409 naming workflows/score.yaml, the fork still active, the remote's new workflow not installed, and Commit refused. Resolved and git added in the working copy, the next Pull applies the merge, and a Commit pushes it.
  • Clean pull. A remote change and a remote deletion are pulled and retired.
  • New branch. A commit to a new branch carries a deletion made here; main is untouched.
  • Create repo. Into an empty remote; a non-empty one is refused; only Create's originals can be retired; Close keeps the items.
  • Token. A remote hook prints the env it was pushed with, which includes the token on a local push, and the 409 answer masks it. The token is in neither repos.json, any answer, nor the console.
  • package/commit. Refused as a direct step and at the door without an accepted proposal; nothing is pushed.
  • A name taken twice.
    • A first Open over local items under the prefix is refused, keeping nothing: no repo on file, no working copy, the local versions untouched.
    • While open, a person's install, the distro's and an uninstall of the same name are refused; after Close, the package uninstalls and the name installs from elsewhere.
    • Create repo for a name installed here, or with items under it, is refused, and the remote stays empty.
    • In install.test.ts, on Ladybug: the repo door refuses a page made here on a first Open and adopts it, as the same node, with adoptPages.
  • Adding to a repo.
    • A new workflow, step, _ helper and app under an opened repo's prefix are accepted. Commit writes them to the remote (the app's sources, no dist/), and they become the package's.
    • The same kind of name under a package installed from file: is still refused, with the hint.
    • After Close, a new name is refused again. The uncommitted item stays and is edited; once reopened, Commit writes it.

There is also a conformance case for stamp (file + Ladybug stores), and the builder seed tests now list package/commit. Full npm test: 4249 pass, 0 fail beyond the two seed assertions this PR updates. cd web && npx tsc --noEmit && npx vite build passes.

Checked in the browser pane against a local strut on local bare remotes: Open repo, the edited badge after a meta/edit-workflow, Commit with the diff straight to main, a Pull conflict and its banner, resolve + Pull, and Create repo from a package page with the retire offer.

@Evanfeenstra
Evanfeenstra merged commit eca1278 into main Nov 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant