Index
# 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 `:`.