# mmo.git — overview for AI code generation
`mmo.git` (PHP package `mmo.git`, PHP namespace `MMO\git`) is a thin object wrapper around the `git` command
line for MMOFramework (`mmo.core`): it builds `git` invocations, runs them as a real child process, parses
what `git` printed into typed objects, and throws an `MMO\core\exceptions\eRuntime` when the command fails
instead of returning a string nobody checks. It also ships a small client for the GitHub REST API
(`MMO\git\GitHubAPI`) for the things a local clone cannot answer on its own (pull requests, issues, releases,
what a branch points at on the server).
## Core classes
- **`MMO\git\Git`** — the package's singleton configuration module (`Git::Shared()`, never `new Git()`): which
`git` binary to run (`bin`, default `'git'`), whether to prefix every call with `sudo`, and the per-command
timeout (`timeout`, seconds, default `60`, `null` = no limit). `Git::Shared()->open($folder)` and
`Git::Shared()->openRemote($ssh, $folder)` are the normal entry points into a repository.
- **`MMO\git\helpers\Repository`** — a local repository, driven by shelling out to `git` in that folder via
`MMO\core\server\Process`. **Not** a singleton — it is a plain, constructible, `iSerializable` object
(`new Repository($folder)` works directly, and it can be declared in a configuration file with
`{"$": "MMO\\git\\helpers\\Repository", "filename": "{root}"}`, which is how `mmo.admin`'s
`ProductionController` receives one).
- **`MMO\git\helpers\SSHRepository`** (extends `Repository`) — the same object, but every command runs over an
already-connected `MMO\core\server\SSH` instead of a local process.
- **`MMO\git\GitHubAPI`** — a singleton (`GitHubAPI::Shared()`) thin client for `api.github.com`, built on
`MMO\core\server\Curl`.
- **`MMO\git\helpers\File`** / **`MMO\git\helpers\Module`** (both extend `MMO\git\classes\aEntry`, implementing
`MMO\git\interfaces\iEntry`) — one entry per changed path after `Repository::update()`: `File` is a plain
file, `Module` is a submodule (itself a full `Repository`, since it extends `Repository`).
## The things that matter most
1. **`open()`/`openRemote()`, not `new Repository()`, is the normal way in.** `Git::Shared()->open($folder)`
resolves `$folder` through `\MMO\filename()` (so `{root}`-style placeholders work) and returns a
`Repository`. `Git::Shared()->openRemote($ssh, $folder)` returns an `SSHRepository` bound to that SSH
connection. Both are freely constructible directly too (`new Repository($folder)`), which is what lets a
`Repository` be declared straight in a configuration file.
2. **Nothing is read until you call `update()`.** A fresh `Repository` only knows its folder. `update()` runs
`git status --porcelain=2 --branch -z` (the `-z` NUL-separated machine format, so filenames with spaces,
quotes or newlines survive intact) and fills in the branch, upstream, ahead/behind counts, and the
`getChangedEntries()` map (`relativeFilename() => File|Module`, keyed by path). Call it again to refresh —
nothing is cached beyond the last `update()`. `getBranch()`, `getCommitHash()`, `getCommitVersion()`, etc.
run their own one-off `git` calls independently and do **not** require `update()` first.
3. **Every `git` call goes through `Repository::exec()`, which throws by default.** `exec($cmd, $throwOnExitCode
= true, &$exitCode = null)` runs `git $cmd` (through `sudo` first if configured) via
`MMO\core\server\Process` in the repository's folder, and throws `eRuntime` (with `git`'s stderr, or stdout
as a fallback, as the message) on a non-zero exit. Pass `false` as the second argument, or use
`test($cmd): bool`, when a non-zero exit is an expected answer rather than an error (e.g. checking whether a
ref exists). `execTrim()` is `exec()` with the result `trim()`-ed, which is what most one-line/porcelain-free
calls (`getBranch()`, `getCommitHash()`, ...) actually use internally.
4. **Shell-command construction and its safety — read this before passing any caller-influenced string
in.** `Repository` builds every `git` invocation by string concatenation and runs it through a real shell
(`MMO\core\server\Process` runs the command as `exec ` via `proc_open`, which goes through `/bin/sh` on
non-Windows). Every *data* argument that Marc considered "not a literal from the source" — branch names,
commit messages, tags, refs, remote URLs, filenames, diff filters, the format string handed to
`getCommit()` — is passed through `Repository::Escape()` (a thin wrapper over `\MMO\core\shell\escape()`)
before being concatenated in. On non-Windows, `\MMO\core\shell\escape()` (`mmo.core`'s
`src/functions/shell.php`) wraps its argument in **single quotes**, escaping any embedded `'` with the
standard `'\''` technique — **except an argument that already starts and ends with `'`, which it returns
unchanged** and which is therefore not protected: `'a'$(touch x)'b'` comes back as is, and run through
`MMO\core\server\Process` the way `Repository::exec()` does, the `touch` runs (verified with a real script).
So `Escape()` alone does not make a caller-supplied value safe: refuse (or strip) one that starts and ends
with a quote before it gets here, and re-verify with a real script if `mmo.core`'s `shell.php` ever changes.
That said, two things in `mmo.git` specifically are worth knowing before trusting *any* caller-supplied
string into it blindly:
- **`Repository::hasDiff()`'s `$files` parameter is concatenated raw, unescaped** (`... . ($files? " --
$files" : '')`), because it is meant to carry a whole pathspec list, not one value `Escape()` could wrap
as a single argument. Never build it from a single untrusted string — escape each path yourself
(`Repository::Escape($path)`) and join them, or keep it to literals from your own source.
- Every `$options` / `$option` / `$opts` parameter across the class (`push()`, `pull()`, `commit()`,
`merge()`, `fetch()`, `add()`, `remove()`, `archive()`, `mtime()`) is **always** concatenated
raw, by design — these carry `git` flags (`--force`, `-b`, `--follow-tags`, ...), not user data, and are
expected to come from your own source, the same way `README.md` already documents for this package.
- A local `Repository`'s own folder is never part of the shell string at all — `exec()` passes it to
`Process` as a separate `$cwd` argument, which `proc_open` receives out-of-band, not shell-interpolated.
`SSHRepository`, however, *does* interpolate its folder into the remote command line (as a `cd`, which
`SSH` quotes with its own `SSH::quote()` — always single quotes, without `escape()`'s exception).
5. **A submodule is a full `Repository`.** `Module extends Repository`: once you have one from
`listModules()` or from `getChangedEntries()`, every `Repository` method works on it directly, and its own
`list()`/`update()` etc. act on the submodule's own tree. `relativeFilename()` on an entry answers relative
to *its own* parent, so a submodule's file is prefixed with the submodule's path only when you asked the
superproject for it (`Repository::list()` does this by concatenating `"$file/$f"` itself, not the entries).
6. **Status is a bitmask, not an enum.** `iEntry`'s constants split into index (staged) flags
(`INDEX_MODIFIED`, `INDEX_ADDED`, ...), worktree (unstaged) flags (`WORKTREE_MODIFIED`, ...), and
module-only flags (`TRACKED_CHANGES`, `UNTRACKED_CHANGES`, `COMMIT_CHANGED`, from the submodule ``
field). Test with `&`, not `===`; `getStatusText()` / `aEntry::StatusText()` turn the mask into human text.
## Usage
```php
use MMO\git\Git;
// optional: point at a non-default git, or run it through sudo (e.g. apache doesn't own the repo it serves)
Git::Configure(['bin' => '/usr/local/bin/git', 'sudo' => false, 'timeout' => 300]);
$repo = Git::Shared()->open('{root}');
echo $repo->getBranch(); // 'develop' (or the short hash on a detached HEAD)
echo $repo->getCommitHash(); // 'e62266b'
echo $repo->getCommitVersion(); // '2.1.0.3.ge62266b' (normalized), or '1.0.0.g' with no tag yet
if ($repo->update()->hasChanges()) {
foreach ($repo->getChangedEntries() as $entry) {
echo $entry->relativeFilename(), ': ', $entry->getStatusText(), "\n";
// 'src/Git.php: untracked'
}
}
// stage, commit, tag, push -- every value below goes through Repository::Escape() before it reaches the shell
foreach ($repo->getChangedEntries() as $entry) {$entry->addToCommit();}
if ($repo->commit('release notes for ' . $version)) { // false: nothing was staged, not an error
$repo->tagVersion('[build] ' . date('D d M Y H:i:s')); // next v* tag, annotated
$repo->push('', '', '--follow-tags');
}
```
### Over SSH
```php
use MMO\core\server\SSH;
use MMO\git\Git;
$ssh = new SSH('deploy', 'example.com', privateKey: '{root}/keys/deploy');
$ssh->connect();
$repo = Git::Shared()->openRemote($ssh, '/var/www/project');
echo $repo->pullAsync()->getContents(); // non-blocking (SSHStream); pull() is the synchronous form
```
### GitHub
```php
use MMO\git\GitHubAPI;
GitHubAPI::Configure(['token' => $token]); // omit: only public endpoints work, rate-limited to 60/hour/IP
$github = GitHubAPI::Shared();
$github->setRepository(GitHubAPI::RepositoryName($repo)); // reads it from the repo's own remote, e.g. 'MarcMouret/MMO-git'
foreach ($github->getPullRequests() as $pr) {echo $pr['number'], ': ', $pr['title'], "\n";}
```
## Uncertain / worth double-checking
- The Escape/injection finding above (point 4) was verified by *executing* real code on this specific
checkout, on Linux, against the `escape()` implementation currently in `mmo.core`'s
`src/functions/shell.php` — it is a fact about that function's current source, not a permanent guarantee.
If a future `mmo.core` version changes `escape()` (or if you are ever on Windows, where `escape()` uses
double quotes and a different, unverified code path), re-run the same kind of check rather than assuming
this document is still accurate.
- `Repository::show()` builds its argument as `"$revision:$filename"` and escapes that *whole* string as one
unit; this is correct for `git show`'s `:` syntax, but was not separately stress-tested against a
filename containing a literal `:`.