version-control · diff
git:20261001.8e9b3cf to git:20261003.4049946
62 added, 71 removed. Audit A to A.
---
name: version-control
description: Read and change a hosted repository through forge-neutral verbs. Version control here is abstracted — remote operations go through vcs.py, local operations use a credential-free VCS binary on a working copy that vcs.py puts on disk. Covers history, file modes, proposals and issues, so no forge CLI is needed.
---
# version-control - remote operations through vcs.py, local operations with git
Version control here is **abstracted**. Everything is one of two things, and
getting an operation into the right half is most of using this skill.
**Remote operations — anything that crosses the network or spends a
credential.** These go through `scripts/vcs.py`, which speaks to a broker in
another container. Only the broker holds the credential; nothing here does.
The remote verbs are exactly these:
`capabilities`, `identity`, `clone`, `publish`,
`proposal create|list|view|comment|update|close|commits|acknowledge`,
`issue create|list|view|comment|update|close`, `label ensure`,
`remote-branch view|delete`.
`update` edits a title or body and adds or removes labels; `close` closes;
`commits` lists the revisions on a proposal's source branch; `acknowledge`
reacts to a comment (its `id` and `kind` come from `view --comments`) so its
author sees it was read — `capabilities` says whether this forge supports it.
`issue list --query <text>` searches, and `--without-labels` skips issues
carrying any of them — that is how a queue is read for unclaimed work.
`label ensure` creates a label or updates it if it exists. `identity` says
who this install is on the forge and, with `--login`, whether that login may
write to the repository. `remote-branch view` says whether the shared
repository still holds a branch and at which revision; `remote-branch delete`
removes one only when it is spent — under `platform-agent/`, no open proposal
on it, and its tip exactly what a merged or closed proposal this install opened
from this repository carried — and only
at the `--revision` you just read.
**Local operations — everything else.** `clone` unpacks a real working copy
- onto this filesystem and prints its `path`. Inside it, use the local git, which
- is `/opt/vcs/libexec/git`. Call it by that full path every time —
- `/opt/vcs/libexec/git log`, `show`, `blame`, `grep`, `diff`, `status`, `branch`,
- `commit`, `ls-files --stage` — and every read works as you expect. Do **not**
- put it in a shell variable or an alias. The command scanner refuses a command
- whose program is a variable (`$G log`), and in an unattended run that refusal is
- final. Each command you run here arrives in a fresh non-interactive shell, which
- never expands aliases, so an aliased `git` followed by `git log` silently runs
- the other program. Read files in the working copy with `cat`, `rg`, or anything
- else. You do not need `vcs.py` for any of this and it is faster without it.
+ onto this filesystem and prints its `path`. Inside it, use `git` —
+ `git log`, `git show`, `git blame`, `git grep`, `git diff`, `git status`,
+ `git branch`, `git commit`, `git ls-files --stage` — and every read works as you
+ expect. Read files in the working copy with `cat`, `rg`, or anything else. You
+ do not need `vcs.py` for any of this and it is faster without it.
- The full path matters: plain `git` on this machine is a different program that
- runs elsewhere and holds a credential. The one named above holds none and
- cannot reach a forge: its HTTP transport helpers are not in the image, so an
- `https://` URL fails with `'remote-https' is not a git command`, and there is
- no ssh client, so an `ssh://` URL fails with `cannot run ssh`. Seeing either
- message means you used the right git and asked it for the one thing it does not
- do; the answer is a `vcs.py` verb, not the other binary.
+ This `git` holds no credential and cannot reach a forge: its HTTP transport
+ helpers are not in the image, so an `https://` URL fails with
+ `'remote-https' is not a git command`, and there is no ssh client, so an
+ `ssh://` URL fails with `cannot run ssh`. Seeing either message means you asked
+ it for the one thing it does not do; the answer is a `vcs.py` verb. There is no
+ `gh` here either, for the same reason.
`vcs.py` also offers `log`, `show`, `annotate`, `files`, `grep`, `diff`,
`status`, `branch` and `commit` as thin wrappers over that same local git, for
when you want their JSON output. They make no network call. Use whichever you
prefer; the wrapper is a convenience, not the sanctioned path.
The reason for the split is that the history is here rather than there. `clone`
brings a repository down as a git bundle, so the full object graph — every
commit, every parent, every tree entry with its mode — is on this disk. Your
revisions go **up** the same way, as a bundle, which is why a branch of five
commits arrives as five commits. Nothing that came out of the repository is
ever executed beside the credential.
The script is `scripts/vcs.py`. Every subcommand prints one JSON object on
stdout. `--repo` takes `owner/name` or a full URL, and the broker decides which
forge that is.
Verb names are the version-control concept; the spelling you know is an alias.
`annotate`/`blame`, `log`/`history`, `files`/`manifest`, `grep`/`search`,
`publish`/`push`, `proposal`/`pr`/`mr`, `create`/`open` all work.
## When to Use
- **Anything about the past.** When a value changed, which revision removed a
flag, who last touched a file, what a file looked like three revisions ago.
- **File modes.** Whether a script is executable is a tree-entry property;
`files` reports it and it survives the round trip.
- **Changing a repository** and opening the change proposal for it.
- **Issues and proposals.** Use `issue` and `proposal` rather than a forge CLI.
- **A repository on a forge that is not GitHub.** Run `capabilities` first — it
answers with what this install can and cannot do for that host, and it spends
no credential doing it.
## When NOT to Use
- **A repository this install does not manage.** Every verb that spends the
credential, `clone` and the list/view verbs included, is refused for a
repository outside the install's managed list, because the credential is
minted per repository; only `capabilities` answers for one. For "how does
upstream implement this", use **inspect-repository**.
- **A one-off read of a large repository.** `clone` pulls a whole branch's
history and there is no shallow option; **inspect-repository** pages a
shallow view and is cheaper.
- **The GitOps write flow that already gave you a workspace.** `fleet-audit`
and `submit-suggestion` own theirs; do not open a second view.
## Read
One remote call, then local work:
```bash
V="$HERMES_HOME"/skills/version-control/scripts/vcs.py
# Remote: one call, one bundle. Its JSON carries `path` — cd there.
python3 $V clone https://github.com/acme/infra
# Local, in that working copy. No network, no credential, no vcs.py.
- # The path is the point: bare `git` is a different, credentialed program,
- # and neither an alias nor a variable in its place would run here.
- /opt/vcs/libexec/git log -n 20 -- inventory/clusters.yaml
- /opt/vcs/libexec/git show HEAD~3:inventory/clusters.yaml
- /opt/vcs/libexec/git blame scripts/rotate-keys.sh
- /opt/vcs/libexec/git ls-files --stage
- /opt/vcs/libexec/git grep 'nodeCount:'
- /opt/vcs/libexec/git status
+ git log -n 20 -- inventory/clusters.yaml
+ git show HEAD~3:inventory/clusters.yaml
+ git blame scripts/rotate-keys.sh
+ git ls-files --stage
+ git grep 'nodeCount:'
+ git status
```
The same reads through `vcs.py`, if you want JSON instead:
```bash
python3 $V log -n 20 -- inventory/clusters.yaml
python3 $V show HEAD~3:inventory/clusters.yaml
python3 $V annotate scripts/rotate-keys.sh
python3 $V files
python3 $V grep 'nodeCount:'
python3 $V status
```
`clone` prints the working copy's `path`. Read files in it with ordinary tools.
Every `vcs.py` verb after the first infers the repository from the only copy
there is, or from the directory you are standing in; `--repo` says which when
there are several.
One repository can be cloned more than once here — the copy is named for its
branch as well as its repository, so a card working a _different_ branch of the
same repository gets a copy of its own rather than yours. That is naming, not a
permission boundary, and it does not separate two cards reading the _same_
branch: with no `--branch` every reader asks for the trunk and every reader
lands on the one directory. The second `clone` replaces what is there, and
refuses to when that copy holds work that was never published. When two copies
of one repository exist, `--repo` no longer picks between them: **run the verb from inside the
copy you mean.** The refusal names the paths.
## Write
```bash
python3 $V branch fix/replicas
# edit files under the path `clone` printed, then:
python3 $V commit inventory/clusters.yaml -m 'raise replicas to 5'
python3 $V publish
python3 $V proposal create --title 'Raise replicas' \
--body 'Rollout headroom for the evening peak.'
python3 $V discard
```
`branch` and `commit` are local and make no network call. `publish` sends every
revision made since the clone, and the identifiers `log` printed here are the
identifiers that land on the forge.
**Name the paths you mean.** `commit` with no paths records changes to files
the copy already tracks and nothing else — it is never `git add .`. A file the
copy has never seen is refused by name, because the working copy is also where
your scratch output lands and a log swept into a public proposal cannot be
taken back. Name it on the `commit` line to include it.
## Collaborate
```bash
python3 $V issue list --state open --labels bug
python3 $V issue list --state open --without-labels status:in-progress
python3 $V issue view 42 --comments
python3 $V issue create --title 'Cluster drift on prod-eu' --body '...'
python3 $V proposal list
python3 $V proposal view 17 --comments --diff
python3 $V proposal comment 17 --body 'Rebased on main.'
```
## Rules
- **`clone` before any other verb.** The read verbs answer from the local copy
and say so when there is not one. The collaboration verbs do not need one if
you pass `--repo`.
- - **Do not reach for `gh`, even though it answers.** A forge CLI is reachable
- on this machine and it is not the sanctioned path: it answers a
- forge-neutral question in one forge's dialect, and the same request against
- the next forge this install adds would have to be written again. Nothing this
- skill cannot do becomes possible through it. A verb you need and cannot find
- is a gap worth reporting, not a reason to go around.
+ - **Do not look for `gh` or another forge CLI.** None is on this machine, and
+ none is needed: one would answer a forge-neutral question in one forge's
+ dialect, and the same request against the next forge this install adds would
+ have to be written again. The `vcs.py` verbs are the path. A verb you need and
+ cannot find is a gap worth reporting, not a reason to go around.
- **Do not `git push`, `git fetch`, `git clone` or `git remote add`.** The
working copy has no remote on purpose, and the local git cannot speak the wire
protocol in any case. Revisions go up through `publish` and come down through
`clone`. Local git is for reading and committing, nothing else.
- **Start a branch before you commit.** `clone` leaves you on the shared branch,
and publishing that branch is refused: revisions reach a repository through a
branch of your own and a proposal onto the shared one. `vcs.py branch <name>`
or `git switch -c <name>` (the local git) before the first commit.
- **`publish` can be refused, and the refusal is the answer.**
`NOT_FAST_FORWARD` and `BRANCH_DIVERGED` mean your revisions do not build on
what the remote has; `BASE_MOVED` means the target branch was rewritten, so
the revision you cloned at is not on it any more and there is nothing to build
on — clone again and reapply the change. An ordinary push to the target by
somebody else is _not_ refused: your proposal simply opens with a base behind
the tip, which is a rebase on the forge and not a problem here. Do not try to
force any of them. `BRANCH_DIVERGED` on a `platform-agent/` name whose last
proposal was closed or squash-merged is a spent branch in the way:
`remote-branch view` reads its revision and `remote-branch delete` clears it.
That delete refuses with `BRANCH_NOT_OURS` (not under `platform-agent/`, or
the closed proposal is not this install's, or it has carried a full page of
proposals, too many to read, or an open proposal targets it), `OPEN_PROPOSAL`, `NOT_SPENT` (it holds revisions
no proposal carried); treat each as somebody's work — pick another name or
report it. `DELETE_REFUSED` means the remote itself refuses
the delete (a branch rule, a hook, or a credential without the right); it
answers every attempt alike, so pick another name or report it too. Never open and close a proposal on a branch to make
it deletable: the delete reads that proposal as proof the branch is yours. `BRANCH_MOVED` means it moved since you read it:
view it again, and delete only if it is still spent.
`PROTECTED_BRANCH` is never yours to clear. `FORGE_CALL_FAILED` and
`GIT_FAILED` mean the delete did not complete, and it may have landed before
the failure: read the branch again, and retry once if it is still there.
`FORGE_RATE_LIMITED` and `FORGE_UNAVAILABLE` from the delete are the forge
turning it away for now: wait, then do the same. A forge without `proposal-list`
does not list `remote-branch delete` in `capabilities` and refuses it
`FORGE_UNSUPPORTED`.
- **A forge refusal names the code and the next move; do what it says.**
`FORGE_RATE_LIMITED` means wait and then use fewer, wider calls.
`FORGE_UNAUTHENTICATED`, `FORGE_FORBIDDEN` and `FORGE_REJECTED` will answer
the same way however many times you repeat the call — report or fix the
argument instead. `FORGE_UNAVAILABLE` means retry unchanged in a few minutes.
`FORGE_NOT_FOUND` does not prove the thing is missing: a private repository
this install cannot see answers the same way.
- **`discard` when finished.** It removes the local copy. Nothing is held on the
credential side, so there is nothing else to release.
- **Read `exitCode` and `stderr`.** The read verbs pass git's own exit status
through; a `log` that returned nothing because the pathspec matched no file is
not the same answer as one that returned nothing because the file has no
history.
- **Say which repository and which branch** in anything you report.
- **`capabilities` before assuming a non-GitHub forge works.** GitLab and
Bitbucket parse their specs and then tell you exactly what this install is
missing. That is the answer, not a bug to work around.
## Reference
- | Subcommand | What it does |
- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
- | `capabilities` | What this install can do for this repository's forge, before anything is spent |
- | `clone` | The history down as a bundle, unpacked into a local working copy; `--branch` for one line |
- | `log` | The revisions behind HEAD; `--patch` for diffs, `--format` a pretty format string, trailing args a pathspec |
- | `show` | One revision, or `revision:path` for a file as of that revision |
- | `diff` | Differences in the working copy, or against `--revision` |
- | `annotate` | Per-line last-change attribution for one path |
- | `files` | Tracked paths with the mode the revision records |
- | `grep` | Text search over the working copy; `--regex`, `--ignore-case` |
- | `status` | What the working copy has that its revision does not |
- | `branch` | List lines of development, or start one. Local |
- | `commit` | Record a revision locally, with a real parent and identifier. Paths, or tracked changes only |
- | `publish` | Send the revisions made since `clone` to the shared repository |
- | `discard` | Remove the local copy; `--branch` when the repository is cloned once per branch |
- | `proposal create` | Open the forge's change proposal (pull request, merge request) |
- | `proposal list` | Open proposals; `--state open\|closed\|all`, `--source`/`--target` to ask about one branch, `--page` for the next page |
- | `proposal view` | One proposal; `--comments` for the discussion, `--diff` for the patch |
- | `proposal comment` | Reply on a proposal |
- | `proposal update` | Retitle, rewrite the body, `--add-label`/`--remove-label` |
- | `proposal close` | Close it without merging |
- | `proposal commits` | The revisions on its source branch, **oldest first**; the last entry is the tip only when `"truncated": false`; `--page` for the next page |
- | `proposal acknowledge` | React to one comment so its author sees it was read; needs `--comment-id` and `--kind` from `view --comments` |
- | `issue list` | Work items; `--state`, `--labels`, `--without-labels`, `--query` |
- | `issue view` | One issue; `--comments` for the discussion |
- | `issue create` | Open an issue; `--labels` |
- | `issue comment` | Reply on an issue |
- | `issue update` | Retitle, rewrite the body, `--add-label`/`--remove-label` |
- | `issue close` | Close it; `--reason completed\|not-planned` |
- | `label ensure` | Make the label exist, or update its `--color`/`--description` if it already does |
- | `identity` | Who this install is on this forge; `--login` asks whether that account may write here, `--bot` if `view --comments` said it is an automation |
- | `remote-branch view` | Whether the shared repository holds this branch, and its `revision` if it does |
- | `remote-branch delete` | Delete a spent `platform-agent/` branch at `--revision`; refused while a proposal on it is open or when it holds revisions none carried |
+ | Subcommand | What it does |
+ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `capabilities` | What this install can do for this repository's forge, before anything is spent |
+ | `clone` | The history down as a bundle, unpacked into a local working copy; `--branch` for one line |
+ | `log` | The revisions behind HEAD; `--patch` for diffs, `--format` a pretty format string, trailing args a pathspec |
+ | `show` | One revision, or `revision:path` for a file as of that revision |
+ | `diff` | Differences in the working copy, or against `--revision` |
+ | `annotate` | Per-line last-change attribution for one path |
+ | `files` | Tracked paths with the mode the revision records |
+ | `grep` | Text search over the working copy; `--regex`, `--ignore-case` |
+ | `status` | What the working copy has that its revision does not |
+ | `branch` | List lines of development, or start one. Local |
+ | `commit` | Record a revision locally, with a real parent and identifier. Paths, or tracked changes only |
+ | `publish` | Send the revisions made since `clone` to the shared repository |
+ | `discard` | Remove the local copy; `--branch` when the repository is cloned once per branch |
+ | `proposal create` | Open the forge's change proposal (pull request, merge request) |
+ | `proposal list` | Open proposals; `--state open\|closed\|all`, `--source`/`--target` to ask about one branch, `--labels` for all of these, `--page` for the next page |
+ | `proposal view` | One proposal; `--comments` for the discussion, `--diff` for the patch |
+ | `proposal comment` | Reply on a proposal |
+ | `proposal update` | Retitle, rewrite the body, `--add-label`/`--remove-label` |
+ | `proposal close` | Close it without merging |
+ | `proposal commits` | The revisions on its source branch, **oldest first**; the last entry is the tip only when `"truncated": false`; `--page` for the next page |
+ | `proposal acknowledge` | React to one comment so its author sees it was read; needs `--comment-id` and `--kind` from `view --comments` |
+ | `issue list` | Work items; `--state`, `--labels`, `--without-labels`, `--query` |
+ | `issue view` | One issue; `--comments` for the discussion |
+ | `issue create` | Open an issue; `--labels` |
+ | `issue comment` | Reply on an issue |
+ | `issue update` | Retitle, rewrite the body, `--add-label`/`--remove-label` |
+ | `issue close` | Close it; `--reason completed\|not-planned` |
+ | `label ensure` | Make the label exist, or update its `--color`/`--description` if it already does |
+ | `identity` | Who this install is on this forge; `--login` asks whether that account may write here, `--bot` if `view --comments` said it is an automation |
+ | `remote-branch view` | Whether the shared repository holds this branch, and its `revision` if it does |
+ | `remote-branch delete` | Delete a spent `platform-agent/` branch at `--revision`; refused while a proposal on it is open or when it holds revisions none carried |
Every listing verb takes `-n/--limit` and answers with `count` and `truncated`.
`truncated` is the forge's word for "there was more", judged on what it sent
rather than on what survived filtering — so `count: 0` with `truncated: true` is
a real answer and means ask again, more narrowly. `proposal list` and
`proposal commits` are the two you may need to read to the end, and they take
`--page 2`, `--page 3`, … for the rest; the last page is the one that answers
`truncated: false`.
`proposal view --comments` and `issue view --comments` say the same thing about
the conversation they read, as `commentCount` and `commentsTruncated`. Take
- `commentsTruncated: true` seriously before you reply to anything: it means you
- are looking at the oldest page of a longer thread, so the most recent word on
- the subject — including an answer somebody already gave — is not in front of
- you. There is no way to read the rest from here: `-n` can only make the page
- smaller, and it is the same oldest page either way. Say that you could not read
- the whole thread, and do not answer anything that turns on what the rest of it
- says. It is the one truncation where carrying on quietly
+ `commentsTruncated: true` seriously before you reply to anything: it means the
+ thread is longer than the `-n` you asked for and you are looking at its oldest
+ comments, so the most recent word on the subject — including an answer somebody
+ already gave — is not in front of you. Ask again with a larger `-n`; a read
+ takes up to 1,000 comments. If it is still truncated at 1,000, say that you
+ could not read the whole thread, and do not answer anything that turns on what
+ the rest of it says. It is the one truncation where carrying on quietly
produces a confidently wrong answer rather than an incomplete one.