Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c21a3c50f0 | ||
|
|
0030334e5e |
@@ -1,35 +1,182 @@
|
||||
# RepoFoundry
|
||||
|
||||
RepoFoundry (`src/create-project.sh`) sets up a new project: a Gitea
|
||||
repository, optionally an empty GitHub repository with a push mirror from
|
||||
Gitea to GitHub, and (in a later phase) a local project with the
|
||||
SQA-QC-Framework.
|
||||
RepoFoundry (`src/create-project.sh`) sets up a new project in one run:
|
||||
|
||||
> **Status: work in progress.** The script can validate its configuration,
|
||||
> check both hosts and create the repositories and the mirror. Creating the
|
||||
> local project, and the full installation guide, come in a later phase
|
||||
> (MIL-003). This file so far documents what is needed to run the host steps
|
||||
> safely.
|
||||
- a **Gitea** repository (the source of truth),
|
||||
- optionally an empty **GitHub** repository that receives everything through a
|
||||
**push mirror from Gitea to GitHub**,
|
||||
- and a **local project** with credential-free remotes and the
|
||||
[SQA-QC-Framework](https://git.tirsystem.com/TirSystem/SQA-QC-Framework)
|
||||
added as a git submodule, with its skills, git hooks (and optionally the plan
|
||||
gate) and templates installed.
|
||||
|
||||
## Quick start
|
||||
It is a Bash script. It asks for the repository name, description, visibility
|
||||
and owner (a user or an organization, separately on each host), shows a plan,
|
||||
and only creates anything after you pass `--apply` and answer yes.
|
||||
|
||||
> **Status.** The script is tested with stubbed host APIs and real git against
|
||||
> local repositories (see [Development](#development)). A first run against
|
||||
> real GitHub and Gitea repositories is still to be recorded.
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Installation](#installation)
|
||||
2. [Configuration](#configuration)
|
||||
3. [Usage](#usage)
|
||||
4. [SSH access to Gitea](#ssh-access-to-gitea)
|
||||
5. [Token permissions](#token-permissions)
|
||||
6. [Security decisions](#security-decisions)
|
||||
7. [Error handling and recovery](#error-handling-and-recovery)
|
||||
8. [Known limitations](#known-limitations)
|
||||
9. [Code layout](#code-layout)
|
||||
10. [Development](#development)
|
||||
11. [Stakeholders](#stakeholders)
|
||||
12. [License](#license)
|
||||
|
||||
## Installation
|
||||
|
||||
Requirements:
|
||||
|
||||
| Tool | Needed for |
|
||||
| --- | --- |
|
||||
| bash 4.4 or later | the script (macOS ships 3.2: install a newer bash first) |
|
||||
| `git` | the local project and the framework submodule |
|
||||
| `curl` | the GitHub and Gitea APIs |
|
||||
| `mktemp` and the usual base tools | temporary files and small helpers |
|
||||
| `ssh` (optional) | the SSH check; without it the framework steps are skipped |
|
||||
| `jq` (optional) | JSON parsing; without it a small built-in reader is used |
|
||||
|
||||
```bash
|
||||
git clone https://git.tirsystem.com/TirSystem-BashScript/RepoFoundry.git
|
||||
cd RepoFoundry
|
||||
src/create-project.sh --help
|
||||
```
|
||||
|
||||
Nothing has to be installed system-wide: the script runs from the checkout and
|
||||
loads its own files from `src/lib/`.
|
||||
|
||||
## Configuration
|
||||
|
||||
The script reads two plain files from the project root. They are **parsed,
|
||||
never executed** (`source` is not used): only `KEY=VALUE` lines with known keys
|
||||
are accepted, and anything else stops the run with a message that names the key
|
||||
and the line, never the value.
|
||||
|
||||
```bash
|
||||
cp config.env.example config.env # service addresses, not secret
|
||||
cp .env.example .env # credentials: keep private
|
||||
chmod 600 .env # Linux and macOS
|
||||
|
||||
src/create-project.sh # dry run: reads from the hosts, creates nothing
|
||||
src/create-project.sh --apply # creates the repositories and the mirror
|
||||
```
|
||||
|
||||
Without `--apply` the script only reads from GitHub and Gitea (it checks the
|
||||
tokens, the owners, the name, the license and SSH) and prints a plan. With
|
||||
`--apply` it prints the plan again and asks a final question before it creates
|
||||
anything. Nothing is ever deleted by the script.
|
||||
### `config.env` (service addresses)
|
||||
|
||||
Choosing GitHub also applies the AGPL-3.0 license to the Gitea repository, so
|
||||
that repository is not empty. Without GitHub the Gitea repository is created
|
||||
empty and has no license.
|
||||
| Key | Meaning | Default |
|
||||
| --- | --- | --- |
|
||||
| `GITHUB_API_URL` | GitHub REST API base URL | `https://api.github.com` |
|
||||
| `GITHUB_WEB_URL` | GitHub web base URL (links and the mirror address) | `https://github.com` |
|
||||
| `GITEA_URL` | Gitea base URL (required) | |
|
||||
| `GITEA_API_URL` | Gitea REST API base URL | `GITEA_URL` + `/api/v1` |
|
||||
| `GITEA_SSH_PORT` | SSH port of the Gitea server | `10022` |
|
||||
| `MIRROR_INTERVAL` | how often Gitea pushes to GitHub, e.g. `10m0s` | `10m0s` |
|
||||
| `FRAMEWORK_REPO` | `OWNER/NAME` of the framework on Gitea | `TirSystem/SQA-QC-Framework` |
|
||||
|
||||
Every URL must start with `https://` and must not contain a user name,
|
||||
password, query string or fragment. A credential key in this file is rejected.
|
||||
|
||||
### `.env` (credentials)
|
||||
|
||||
| Key | Meaning |
|
||||
| --- | --- |
|
||||
| `GITEA_TOKEN` | Gitea access token (required) |
|
||||
| `GITHUB_PAT` | GitHub personal access token (only when you choose GitHub) |
|
||||
| `GITHUB_USER` | the GitHub account the token belongs to; only a default for the owner prompt |
|
||||
|
||||
`.env` is ignored by git. The script warns if it is readable by other users or
|
||||
not ignored by git. See [Token permissions](#token-permissions) for what each
|
||||
token needs.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
src/create-project.sh # dry run: reads from the hosts, creates nothing
|
||||
src/create-project.sh --apply # creates everything after a final yes
|
||||
src/create-project.sh --config /path/to/config.env --env /path/to/.env
|
||||
```
|
||||
|
||||
The script asks for, in this order: repository name, description, visibility,
|
||||
Gitea owner, whether to also create a GitHub repository (and its owner), the
|
||||
local directory and whether to enable the plan gate. It then checks both hosts
|
||||
with read-only requests and prints a plan:
|
||||
|
||||
```text
|
||||
Plan:
|
||||
Gitea repository : create (private) with the AGPL-3.0 license https://git.example.org/Team/my-app
|
||||
GitHub repository : create (private), empty https://github.com/acme/my-app
|
||||
Push mirror : Gitea -> GitHub every 10m0s
|
||||
Local project : create ./my-app (new directory), git on main, no commit
|
||||
Local origin : will use SSH (the SSH test passed)
|
||||
Framework : add ssh://git@git.example.org:10022/Team/SQA-QC-Framework.git as a submodule
|
||||
Skills and hooks : install once; plan gate no
|
||||
Templates : AGENTS.md and docs/artifact-registry.md (you are asked before a file is replaced)
|
||||
```
|
||||
|
||||
Without `--apply` that is all that happens. With `--apply` the script asks
|
||||
"Create these now" (default no) and then creates, in this order:
|
||||
|
||||
1. the GitHub repository (empty), if chosen;
|
||||
2. the Gitea repository (with the AGPL-3.0 license if GitHub was chosen);
|
||||
3. the push mirror Gitea -> GitHub, and a request for its first sync;
|
||||
4. the local directory, `git init` on `main`, the `origin` remote (and `github`
|
||||
if chosen), and, if the Gitea repository holds the license commit, that
|
||||
history;
|
||||
5. the framework as the submodule `framework`;
|
||||
6. the framework's skills and git hooks, and the plan gate if chosen;
|
||||
7. `AGENTS.md` and `docs/artifact-registry.md` from the framework's templates.
|
||||
|
||||
No commit is made in the new project. Work on a branch there: the framework's
|
||||
hooks refuse commits on `main`.
|
||||
|
||||
### Choices
|
||||
|
||||
- **GitHub or not.** Choosing GitHub also applies the AGPL-3.0 license to the
|
||||
Gitea repository (so it is not empty) and sets up the mirror. Without GitHub
|
||||
the Gitea repository is empty and has no license, and `GITHUB_PAT` is not
|
||||
needed.
|
||||
- **Owners.** The Gitea owner and the GitHub owner are chosen separately and
|
||||
may be a user or an organization. `GITHUB_USER` is only the suggested default
|
||||
for the GitHub owner prompt; it identifies who authenticates.
|
||||
- **Plan gate.** If enabled, a commit that changes `src/` or `tests/` in the
|
||||
new project needs a `Task: MIL-NNN#N` trailer.
|
||||
|
||||
### Nothing is overwritten without a yes
|
||||
|
||||
The script asks first (default no) before it uses an existing directory, before
|
||||
it replaces an existing `core.hooksPath`, and before it replaces an existing
|
||||
`AGENTS.md` or `docs/artifact-registry.md`. It never deletes anything, never
|
||||
replaces a remote that points somewhere else, and git itself refuses to
|
||||
overwrite a file when the license history is checked out.
|
||||
|
||||
## SSH access to Gitea
|
||||
|
||||
The framework submodule is fetched over SSH on port **10022**
|
||||
(`ssh://git@<gitea host>:10022/TirSystem/SQA-QC-Framework.git`). Before you run
|
||||
the script:
|
||||
|
||||
1. Add your SSH public key to your Gitea account.
|
||||
2. Connect once by hand so that the server's host key is known (the script
|
||||
refuses unknown host keys and never answers questions for you):
|
||||
|
||||
```bash
|
||||
ssh -p 10022 -T git@git.tirsystem.com
|
||||
```
|
||||
|
||||
A message that you have successfully authenticated, without shell access,
|
||||
means it works.
|
||||
|
||||
The script runs the same check in its dry run. If it fails, the plan says so
|
||||
and, with `--apply`, you are asked whether to create the repositories and the
|
||||
local project **without** the framework steps (they are then reported as
|
||||
skipped). The default answer is no.
|
||||
|
||||
## Token permissions
|
||||
|
||||
@@ -56,10 +203,8 @@ repositories for the chosen owner and to push to the new one.
|
||||
- **Fine-grained tokens:** the GitHub documentation lists no fine-grained
|
||||
permission for creating a repository, and this has not been tested.
|
||||
Use a classic token until it has been.
|
||||
- **`GITHUB_USER`:** names the account the token belongs to. It is only a
|
||||
default for the owner prompt; the repository may belong to an organization.
|
||||
If it differs from the account the token belongs to, the script warns and
|
||||
uses the account the token belongs to.
|
||||
- **`GITHUB_USER`:** if it differs from the account the token belongs to, the
|
||||
script warns and uses the account the token belongs to.
|
||||
|
||||
### Gitea token (`GITEA_TOKEN`)
|
||||
|
||||
@@ -68,11 +213,70 @@ repositories for the chosen owner and to push to the new one.
|
||||
| Read the account the token belongs to | `read:user` | Gitea documentation |
|
||||
| Create repositories, manage the push mirror | `write:repository` | Gitea documentation |
|
||||
| Look up an organization and your permissions in it | `read:organization` | Gitea documentation |
|
||||
| Create a repository in an organization | probably `write:organization` as well | **Not confirmed**: expected from how the Gitea API groups organization calls; the end-to-end test in MIL-003 will confirm it. |
|
||||
| Create a repository in an organization | probably `write:organization` as well | **Not confirmed**: expected from how the Gitea API groups organization calls; to be confirmed in the first end-to-end run. |
|
||||
|
||||
A missing scope shows up as an HTTP 403 with the server's own message. The
|
||||
script stops before it creates anything when a preflight check is refused.
|
||||
|
||||
## Security decisions
|
||||
|
||||
- **Tokens never appear** in output, logs, remote URLs, `.git/config`,
|
||||
`.gitmodules`, command lines or leftover files. They go to `curl` through a
|
||||
private configuration file that is removed right after the request, and to
|
||||
`git` (HTTPS fetch only) through a `GIT_ASKPASS` helper and the environment
|
||||
of that one command. Output is filtered, so even a server message that echoes
|
||||
a token is shown as `[redacted]`. Tests plant fake tokens and search all
|
||||
output and every file of the new project for them.
|
||||
- **No `set -x`.** Tracing would print every secret, so the script switches it
|
||||
off and says so.
|
||||
- **Config files are parsed, not sourced,** with a whitelist of keys; values
|
||||
are validated (URLs must be `https` without credentials, tokens must have a
|
||||
safe character set) and never executed.
|
||||
- **Dry run by default.** Creating anything needs `--apply` and a final yes.
|
||||
- **No destructive commands.** The script never deletes a repository or a
|
||||
file and never uses a recursive delete; temporary files are removed one by
|
||||
one.
|
||||
- **Credential-free remotes.** `origin` is `ssh://git@host:port/owner/name.git`
|
||||
(or plain HTTPS when SSH is not used) and `github` is a plain HTTPS address.
|
||||
- **Redirects are not followed,** so a token is only ever sent to the host in
|
||||
the URL it was meant for. Unknown SSH host keys are refused.
|
||||
- **Framework scripts run on the new project only.** They are run with
|
||||
`PROJECT_ROOT` set explicitly, so a `PROJECT_ROOT` in your environment cannot
|
||||
point them elsewhere. They come from the framework repository you configured:
|
||||
review what you trust there.
|
||||
|
||||
## Error handling and recovery
|
||||
|
||||
Every message starts with `error:`, names what failed and what to do, and never
|
||||
contains a secret. Exit codes: `0` success (or a dry run), `1` a failed check or
|
||||
step, `2` a usage error.
|
||||
|
||||
| What happens | What the script does | What you do |
|
||||
| --- | --- | --- |
|
||||
| A tool, a config key or a token is missing or invalid | stops before any request | fix it and run again |
|
||||
| A token is refused, an owner is unknown, a name is taken, the license is missing | stops in the preflight; nothing was created | fix the cause |
|
||||
| The host cannot be reached | stops with the host name | try again |
|
||||
| A repository already exists and is empty (Gitea: or holds only the license) | offers to reuse it (default no) | answer, or choose another name |
|
||||
| A repository already has content | stops | choose another name or remove it |
|
||||
| A step fails after another succeeded | stops and prints what exists, what failed and how to continue | fix the cause and run the **same command again with `--apply`**: what was created is offered for reuse |
|
||||
| The mirror is refused (disabled, interval too short) | keeps the repositories and reports it | change `MIRROR_INTERVAL` or ask the Gitea administrator, then run again |
|
||||
| The framework submodule cannot be fetched | reports the address and how to test SSH | fix your SSH access, run again |
|
||||
| `sync_on_commit` was ignored by Gitea | warns; the mirror syncs on its interval | enable it in the repository settings if needed |
|
||||
|
||||
A partial run is reported like this:
|
||||
|
||||
```text
|
||||
The run stopped before it finished. This is what exists now:
|
||||
GitHub repository : created https://github.com/acme/my-app
|
||||
Gitea repository : FAILED
|
||||
Push mirror : not attempted
|
||||
...
|
||||
To continue: fix the problem named above and run the same command again with --apply.
|
||||
```
|
||||
|
||||
Nothing is deleted automatically. To start over, delete the repositories in the
|
||||
web interface and the project directory by hand.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **The mirror password is stored on the Gitea server.** Gitea needs the
|
||||
@@ -90,16 +294,15 @@ script stops before it creates anything when a preflight check is refused.
|
||||
the repositories created so far are kept.
|
||||
- **The license commit.** Gitea adds the license file when the repository is
|
||||
created with `auto_init`. The script sends no README, so the repository
|
||||
should hold only `LICENSE`; this is checked in the MIL-003 end-to-end test.
|
||||
- **No rollback.** If a step fails, the script reports what exists and how to
|
||||
continue. A repeated run offers to reuse a repository it created earlier
|
||||
(empty, or in Gitea's case holding only the license). Delete what you do not
|
||||
want in the web interface.
|
||||
should hold only `LICENSE`; this is still to be confirmed against a real
|
||||
server.
|
||||
- **No rollback.** See [Error handling and recovery](#error-handling-and-recovery).
|
||||
- **Mirror direction is Gitea to GitHub only.** Push to Gitea; GitHub is a
|
||||
copy.
|
||||
- **Requirements:** bash 4.4 or later, `git`, `curl` and `mktemp`; `jq` and
|
||||
`ssh` are optional. Without `jq` the script reads the few JSON fields it
|
||||
needs with a simple built-in reader.
|
||||
- **The framework needs SSH.** Without SSH access to Gitea the framework steps
|
||||
can only be skipped.
|
||||
- **Tested on Windows (Git Bash) only so far.** Running the tests on Linux and
|
||||
macOS is an open follow-up.
|
||||
|
||||
## Code layout
|
||||
|
||||
@@ -121,12 +324,15 @@ file. The files are loaded from that directory only, by a fixed path.
|
||||
| `api.sh` | GitHub and Gitea API calls and reporting a refused call |
|
||||
| `prompts.sh` | interactive questions with validation |
|
||||
| `project.sh` | the project details: asking for them and showing them |
|
||||
| `hosts.sh` | names and links of the repositories on each host |
|
||||
| `hosts.sh` | names, links and remote addresses of the repositories |
|
||||
| `preflight.sh` | read-only checks of both hosts |
|
||||
| `steps.sh` | the outcome of each step and the final report |
|
||||
| `plan.sh` | printing what the script is about to do |
|
||||
| `repositories.sh` | creating the GitHub and Gitea repositories |
|
||||
| `mirror.sh` | the Gitea to GitHub push mirror |
|
||||
| `git.sh` | running git for the new project without prompts or tokens on a command line |
|
||||
| `localproject.sh` | the local directory, git repository and remotes |
|
||||
| `framework.sh` | the framework submodule, skills, hooks and templates |
|
||||
| `apply.sh` | confirmations and the apply flow; the only code that changes anything |
|
||||
| `cli.sh` | usage text and option parsing |
|
||||
|
||||
@@ -138,5 +344,28 @@ when it is loaded.
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bash tests/run-tests.sh # shellcheck, shfmt and all tests, no network
|
||||
bash tests/run-tests.sh # shellcheck, shfmt and all tests, no network
|
||||
bash tests/run-tests.sh PATTERN # only tests whose name contains PATTERN
|
||||
```
|
||||
|
||||
The tests stub the two host APIs (a fake `curl` answers from a routes file) and
|
||||
`ssh`, and run real git against local bare repositories that stand in for
|
||||
Gitea and the framework (git's `insteadOf` rewrites the remote addresses), with
|
||||
a private git configuration. Nothing reaches the network and nothing outside
|
||||
the test directories is changed. Mutation checks show that the tests fail when
|
||||
a guarantee is removed.
|
||||
|
||||
Planning documents, reviews and the traceability matrix are in `docs/`; the
|
||||
project follows the SQA and QC framework (see `AGENTS.md`).
|
||||
|
||||
## Stakeholders
|
||||
|
||||
| Who | Role |
|
||||
| --- | --- |
|
||||
| [Tirsvad](https://www.linkedin.com/in/tirsvad74) | Product Owner and maintainer |
|
||||
| [Michael Kragh](https://www.linkedin.com/in/codemikemike/) | DevOps, cybersecurity and maintainer |
|
||||
| GitHub readers | people who read and may reuse this project |
|
||||
|
||||
## License
|
||||
|
||||
GNU Affero General Public License v3.0; see [LICENSE](LICENSE).
|
||||
|
||||
@@ -29,3 +29,8 @@ GITEA_API_URL=https://git.tirsystem.com/api/v1
|
||||
# Optional. How often Gitea pushes to GitHub, as a Go duration (10m0s, 8h0m0s).
|
||||
# The server may enforce a minimum. Default: 10m0s.
|
||||
#MIRROR_INTERVAL=10m0s
|
||||
|
||||
# Optional. OWNER/NAME of the SQA-QC-Framework repository on the Gitea server;
|
||||
# it is added to the new project as a submodule over SSH.
|
||||
# Default: TirSystem/SQA-QC-Framework.
|
||||
#FRAMEWORK_REPO=TirSystem/SQA-QC-Framework
|
||||
|
||||
+25
-13
@@ -4,12 +4,14 @@
|
||||
# Purpose
|
||||
# RepoFoundry creates a Gitea repository, optionally an empty GitHub
|
||||
# repository with a Gitea -> GitHub push mirror, and a local project with
|
||||
# the SQA-QC-Framework. This version (MIL-002) validates the configuration
|
||||
# and credentials, asks for the project details, checks both hosts with
|
||||
# read-only requests (tokens, owners, names, licence, SSH) and, with
|
||||
# --apply, creates the repositories and the mirror. Choosing GitHub also
|
||||
# applies the AGPL-3.0 license to the Gitea repository. The local project
|
||||
# is not created yet.
|
||||
# the SQA-QC-Framework. It validates the configuration and credentials,
|
||||
# asks for the project details, checks both hosts with read-only requests
|
||||
# (tokens, owners, names, license, SSH) and, with --apply, creates the
|
||||
# repositories and the mirror, then the local project: its directory, git
|
||||
# repository, remotes (no credential in any address), the framework as a
|
||||
# submodule, the framework's skills and git hooks (and the plan gate if
|
||||
# chosen) and its templates. Choosing GitHub also applies the AGPL-3.0
|
||||
# license to the Gitea repository. No commit is made in the new project.
|
||||
#
|
||||
# Dry run by default
|
||||
# Without --apply the script only reads from GitHub and Gitea (GET
|
||||
@@ -31,8 +33,9 @@
|
||||
#
|
||||
# Files (parsed, never sourced)
|
||||
# config.env GITHUB_API_URL, GITHUB_WEB_URL, GITEA_URL, GITEA_API_URL and
|
||||
# the optional GITEA_SSH_PORT (default 10022) and
|
||||
# MIRROR_INTERVAL (default 10m0s)
|
||||
# the optional GITEA_SSH_PORT (default 10022), MIRROR_INTERVAL
|
||||
# (default 10m0s) and FRAMEWORK_REPO (default
|
||||
# TirSystem/SQA-QC-Framework, the submodule's OWNER/NAME)
|
||||
# .env GITHUB_PAT, GITHUB_USER, GITEA_TOKEN
|
||||
#
|
||||
# Environment
|
||||
@@ -44,13 +47,15 @@
|
||||
# Requires
|
||||
# bash 4.4 or later, git, curl, mktemp; jq and ssh are optional (jq is used
|
||||
# for JSON when present; ssh is used for the Gitea SSH test).
|
||||
# Also the base tools sed, grep, head, tr, sleep, rm, rmdir and uname, and
|
||||
# Also the base tools sed, grep, head, tr, sleep, find, cp, mkdir, chmod, env, rm,
|
||||
# rmdir and uname, and
|
||||
# stat (GNU "stat -c" or BSD "stat -f"; only used outside Windows).
|
||||
#
|
||||
# Implements
|
||||
# MIL-001 tasks 1 to 6 and MIL-002 tasks 1 to 5 (issues #3 to #13), user
|
||||
# stories US-001.01 and US-001.02, UC-001 steps 1 to 7; see docs/. Deviation
|
||||
# from the request: its second GITEA_URL key is named GITEA_API_URL.
|
||||
# MIL-001 tasks 1 to 6, MIL-002 tasks 1 to 5 and MIL-003 tasks 1 to 4
|
||||
# (issues #3 to #13 and #15 to #18), user stories US-001.01 to US-001.03,
|
||||
# UC-001 steps 1 to 10; see docs/. Deviation from the request: its second
|
||||
# GITEA_URL key is named GITEA_API_URL.
|
||||
#
|
||||
# Tracing
|
||||
# set -x is switched off while the script runs, because a trace would print
|
||||
@@ -61,7 +66,8 @@
|
||||
# the files in lib/ next to it (one job per file, see the first lines of
|
||||
# each file): constants, output, temp, util, validate, config, tools, json,
|
||||
# http, api, prompts, project, hosts, preflight, steps, plan, repositories,
|
||||
# mirror, apply and cli. The files are loaded from this directory only.
|
||||
# mirror, git, localproject, framework, apply and cli. The files are loaded
|
||||
# from this directory only.
|
||||
#
|
||||
# Exit codes
|
||||
# 0 success (or a dry run, or a "no" at the final question), 1 a failed
|
||||
@@ -129,6 +135,12 @@ source "$SCRIPT_DIR/lib/plan.sh"
|
||||
source "$SCRIPT_DIR/lib/repositories.sh"
|
||||
# shellcheck source=lib/mirror.sh
|
||||
source "$SCRIPT_DIR/lib/mirror.sh"
|
||||
# shellcheck source=lib/git.sh
|
||||
source "$SCRIPT_DIR/lib/git.sh"
|
||||
# shellcheck source=lib/localproject.sh
|
||||
source "$SCRIPT_DIR/lib/localproject.sh"
|
||||
# shellcheck source=lib/framework.sh
|
||||
source "$SCRIPT_DIR/lib/framework.sh"
|
||||
# shellcheck source=lib/apply.sh
|
||||
source "$SCRIPT_DIR/lib/apply.sh"
|
||||
# shellcheck source=lib/cli.sh
|
||||
|
||||
+22
-1
@@ -4,7 +4,7 @@
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: create_all, confirm_reuse, apply_plan
|
||||
# Provides: create_all, confirm_reuse, confirm_framework_access, apply_plan
|
||||
|
||||
create_all() {
|
||||
IS_CREATION_STARTED=1
|
||||
@@ -15,6 +15,25 @@ create_all() {
|
||||
if ((PROJECT[has_github])); then
|
||||
configure_mirror
|
||||
fi
|
||||
create_local_project
|
||||
add_framework
|
||||
install_framework
|
||||
copy_templates
|
||||
}
|
||||
|
||||
# confirm_framework_access: the framework comes over SSH. Without SSH the
|
||||
# Maintainer can still go on, without the framework steps, after a yes.
|
||||
confirm_framework_access() {
|
||||
if ((${STATE[is_ssh_ok]:-0})); then
|
||||
STATE[skip_framework]=0
|
||||
return 0
|
||||
fi
|
||||
warn "SSH to Gitea ($(gitea_host) port ${CONFIG[GITEA_SSH_PORT]}) did not work, so the framework cannot be added: ${STATE[ssh_note]}"
|
||||
prompt_yes_no "Create the repositories and the local project without the framework" n
|
||||
if ! ((REPLY)); then
|
||||
die "stopped: set up SSH access to Gitea (see the README) and run again"
|
||||
fi
|
||||
STATE[skip_framework]=1
|
||||
}
|
||||
|
||||
confirm_reuse() {
|
||||
@@ -42,5 +61,7 @@ apply_plan() {
|
||||
return 0
|
||||
fi
|
||||
confirm_reuse
|
||||
confirm_local_directory
|
||||
confirm_framework_access
|
||||
create_all
|
||||
}
|
||||
|
||||
@@ -100,6 +100,9 @@ validate_config() {
|
||||
CONFIG[MIRROR_INTERVAL]="${CONFIG[MIRROR_INTERVAL]:-$DEFAULT_MIRROR_INTERVAL}"
|
||||
is_valid_interval "${CONFIG[MIRROR_INTERVAL]}" ||
|
||||
die "MIRROR_INTERVAL in $CONFIG_FILE must look like 10m0s or 8h0m0s"
|
||||
CONFIG[FRAMEWORK_REPO]="${CONFIG[FRAMEWORK_REPO]:-$DEFAULT_FRAMEWORK_REPO}"
|
||||
is_valid_framework_repo "${CONFIG[FRAMEWORK_REPO]}" ||
|
||||
die "FRAMEWORK_REPO in $CONFIG_FILE must look like OWNER/NAME"
|
||||
}
|
||||
|
||||
validate_credentials() {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# shellcheck disable=SC2034 # read and written by the other library files
|
||||
|
||||
readonly PROJECT_NAME="${REPOFOUNDRY_NAME:-RepoFoundry}"
|
||||
readonly VERSION="0.2.0"
|
||||
readonly VERSION="0.3.0"
|
||||
readonly EXIT_FAILURE=1
|
||||
readonly EXIT_USAGE=2
|
||||
readonly MAX_VALUE_LENGTH=2048
|
||||
@@ -16,12 +16,14 @@ readonly MAX_DESCRIPTION_LENGTH=350
|
||||
readonly HTTP_TIMEOUT_SECONDS=30
|
||||
readonly DEFAULT_MIRROR_INTERVAL="10m0s"
|
||||
readonly DEFAULT_SSH_PORT=10022
|
||||
readonly DEFAULT_FRAMEWORK_REPO="TirSystem/SQA-QC-Framework"
|
||||
readonly AGPL_LICENSE_KEY="AGPL-3.0"
|
||||
readonly DEFAULT_BRANCH="main"
|
||||
readonly PLAN_STEPS=("GitHub repository" "Gitea repository" "Push mirror")
|
||||
readonly PLAN_STEPS=("GitHub repository" "Gitea repository" "Push mirror"
|
||||
"Local project" "Framework" "Skills and hooks" "Templates")
|
||||
# shellcheck disable=SC2034 # read through namerefs (parse_env_file)
|
||||
readonly CONFIG_KEYS=(GITHUB_API_URL GITHUB_WEB_URL GITEA_URL GITEA_API_URL
|
||||
GITEA_SSH_PORT MIRROR_INTERVAL)
|
||||
GITEA_SSH_PORT MIRROR_INTERVAL FRAMEWORK_REPO)
|
||||
readonly CREDENTIAL_KEYS=(GITHUB_PAT GITHUB_USER GITEA_TOKEN)
|
||||
|
||||
CONFIG_FILE="$PROJECT_ROOT/config.env"
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# framework.sh - The SQA-QC-Framework in the new project: submodule, skills, hooks and templates.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: is_framework_skipped, add_framework, run_framework_script, install_skills, install_hooks, install_framework, copy_template, copy_templates
|
||||
|
||||
# is_framework_skipped: succeed when the framework steps are left out because
|
||||
# SSH to Gitea is not available (the Maintainer agreed to that).
|
||||
is_framework_skipped() {
|
||||
[[ ${STATE[skip_framework]:-0} == 1 ]]
|
||||
}
|
||||
|
||||
# add_framework: git submodule add of the framework as "framework". SSH is
|
||||
# needed for it; a failure says how to test the access.
|
||||
add_framework() {
|
||||
local label="Framework" dir="${PROJECT[directory]}" url err existing
|
||||
url="$(framework_url)"
|
||||
if is_framework_skipped; then
|
||||
finish_step "$label" "skipped" "(no SSH access to Gitea)"
|
||||
return 0
|
||||
fi
|
||||
begin_step "$label"
|
||||
if [[ -e $dir/framework ]]; then
|
||||
existing="$(git_project "$dir" config -f .gitmodules --get submodule.framework.url || true)"
|
||||
if [[ $existing != "$url" ]]; then
|
||||
die "'framework' already exists in $dir and is not the framework submodule ($url)"
|
||||
fi
|
||||
finish_step "$label" "reused" "$url (already a submodule)"
|
||||
return 0
|
||||
fi
|
||||
make_temp_file
|
||||
err="$REPLY"
|
||||
if ! git_project "$dir" submodule add -q "$url" framework 2>"$err"; then
|
||||
die "git could not add the framework from $url. Check the SSH access first: ssh -p ${CONFIG[GITEA_SSH_PORT]} -T git@$(gitea_host). Git said: $(head -n 2 "$err" | tr '\n' ' ')"
|
||||
fi
|
||||
finish_step "$label" "created" "$url"
|
||||
}
|
||||
|
||||
# run_framework_script DIR SCRIPT [ARG...]: run one of the framework's own
|
||||
# scripts inside the project. PROJECT_ROOT is set explicitly so that a
|
||||
# PROJECT_ROOT in the caller's environment cannot point it elsewhere.
|
||||
run_framework_script() {
|
||||
local dir="$1" script="$2" out abs
|
||||
shift 2
|
||||
abs="$(cd "$dir" && pwd)"
|
||||
make_temp_file
|
||||
out="$REPLY"
|
||||
if ! (cd "$abs" && env PROJECT_ROOT="$abs" bash "framework/scripts/$script" "$@") >"$out" 2>&1; then
|
||||
die "the framework script $script failed: $(tail -n 3 "$out" | tr '\n' ' ')"
|
||||
fi
|
||||
}
|
||||
|
||||
# install_skills DIR: install the framework's skills once. The installer is
|
||||
# safe to repeat but copies everything again, so a project that already has
|
||||
# them is left alone.
|
||||
install_skills() {
|
||||
local dir="$1"
|
||||
if [[ -f $dir/.agents/skills/.framework-skills && -f $dir/.claude/skills/.framework-skills ]]; then
|
||||
STATE[skills_note]="skills already installed"
|
||||
return 0
|
||||
fi
|
||||
run_framework_script "$dir" install-skills.sh
|
||||
STATE[skills_note]="skills installed"
|
||||
}
|
||||
|
||||
# install_hooks DIR: point core.hooksPath at the framework's hooks, and turn
|
||||
# on the plan gate if chosen. A different hooks path that is already set is
|
||||
# only replaced after a yes.
|
||||
install_hooks() {
|
||||
local dir="$1" current global gate=() gate_enabled
|
||||
if ((PROJECT[is_plan_gate_enabled])); then
|
||||
gate=(--enable-plan-gate)
|
||||
fi
|
||||
# Both settings are normally unset; git config exits 1 then.
|
||||
current="$(git_project "$dir" config --local --get core.hooksPath || true)"
|
||||
global="$(git_project "$dir" config --global --get core.hooksPath || true)"
|
||||
gate_enabled="$(git_project "$dir" config --local --get planGate.enabled || true)"
|
||||
if [[ -z $current && -n $global ]]; then
|
||||
warn "your global core.hooksPath is '$global'; this project sets its own, which takes precedence here"
|
||||
fi
|
||||
if [[ -z $current ]]; then
|
||||
run_framework_script "$dir" install-git-hooks.sh "${gate[@]}"
|
||||
STATE[hooks_note]="hooks installed"
|
||||
elif [[ $current == framework/githooks ]]; then
|
||||
STATE[hooks_note]="hooks already installed"
|
||||
if ((PROJECT[is_plan_gate_enabled])) && [[ $gate_enabled != true ]]; then
|
||||
run_framework_script "$dir" install-git-hooks.sh "${gate[@]}"
|
||||
fi
|
||||
else
|
||||
prompt_yes_no "core.hooksPath is already '$current'. Replace it with framework/githooks" n
|
||||
if ((REPLY)); then
|
||||
run_framework_script "$dir" install-git-hooks.sh "${gate[@]}"
|
||||
STATE[hooks_note]="hooks installed (replaced '$current')"
|
||||
else
|
||||
STATE[hooks_note]="kept the existing hooks path '$current'"
|
||||
if ((PROJECT[is_plan_gate_enabled])); then
|
||||
warn "the plan gate is not enabled because the framework hooks were not installed"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# install_framework: skills, then hooks (and the plan gate). Each is done once.
|
||||
install_framework() {
|
||||
local label="Skills and hooks" dir="${PROJECT[directory]}" gate_state="plan gate off"
|
||||
if is_framework_skipped; then
|
||||
finish_step "$label" "skipped" "(no SSH access to Gitea)"
|
||||
return 0
|
||||
fi
|
||||
begin_step "$label"
|
||||
install_skills "$dir"
|
||||
install_hooks "$dir"
|
||||
if [[ $(git_project "$dir" config --local --get planGate.enabled || true) == true ]]; then
|
||||
gate_state="plan gate on"
|
||||
fi
|
||||
finish_step "$label" "created" "(${STATE[skills_note]}; ${STATE[hooks_note]}; $gate_state)"
|
||||
}
|
||||
|
||||
# copy_template DIR SOURCE TARGET: copy a framework template. An existing
|
||||
# target is only replaced after a yes. The result goes to STATE[template_note].
|
||||
copy_template() {
|
||||
local dir="$1" source="$2" target="$3"
|
||||
if [[ ! -f $dir/$source ]]; then
|
||||
die "the framework has no $source; is the submodule complete?"
|
||||
fi
|
||||
if [[ -e $dir/$target ]]; then
|
||||
prompt_yes_no "$target already exists. Replace it with the framework template" n
|
||||
if ! ((REPLY)); then
|
||||
STATE[template_note]="kept existing $target"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
cp -- "$dir/$source" "$dir/$target"
|
||||
STATE[template_note]="copied $target"
|
||||
}
|
||||
|
||||
# copy_templates: AGENTS.md and docs/artifact-registry.md from the framework.
|
||||
copy_templates() {
|
||||
local label="Templates" dir="${PROJECT[directory]}" notes=""
|
||||
if is_framework_skipped; then
|
||||
finish_step "$label" "skipped" "(no SSH access to Gitea)"
|
||||
return 0
|
||||
fi
|
||||
begin_step "$label"
|
||||
mkdir -p -- "$dir/docs"
|
||||
copy_template "$dir" framework/templates/AGENTS-template.md AGENTS.md
|
||||
notes="${STATE[template_note]}"
|
||||
copy_template "$dir" framework/templates/artifact-registry-template.md docs/artifact-registry.md
|
||||
notes="$notes; ${STATE[template_note]}"
|
||||
finish_step "$label" "created" "($notes)"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# git.sh - Running git for the new project: no prompts, no token on a command line.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: git_project, fetch_origin
|
||||
|
||||
# git_project DIR ARGS...: run git in DIR. Prompts are switched off, so a
|
||||
# missing credential or SSH key fails at once instead of waiting for input.
|
||||
git_project() {
|
||||
local dir="$1"
|
||||
shift
|
||||
GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh} -o BatchMode=yes" \
|
||||
git -C "$dir" "$@"
|
||||
}
|
||||
|
||||
# fetch_origin DIR: fetch the Gitea repository into the project. Over SSH the
|
||||
# user's key is used. Over HTTPS the token reaches git through a private
|
||||
# GIT_ASKPASS helper and the environment of this one command: it is never part
|
||||
# of a URL, of the remote configuration or of a command line.
|
||||
fetch_origin() {
|
||||
local dir="$1" url askpass
|
||||
url="$(git_project "$dir" config --get remote.origin.url)"
|
||||
if [[ $url != https://* ]]; then
|
||||
git_project "$dir" fetch -q origin
|
||||
return
|
||||
fi
|
||||
make_temp_file
|
||||
askpass="$REPLY"
|
||||
# shellcheck disable=SC2016 # the helper is written out literally: it expands $1 and its environment itself
|
||||
{
|
||||
printf '%s\n' '#!/usr/bin/env bash'
|
||||
printf '%s\n' 'case "$1" in'
|
||||
printf '%s\n' ' *sername*) printf "%s\n" "$REPOFOUNDRY_ASKPASS_USER" ;;'
|
||||
printf '%s\n' ' *) printf "%s\n" "$REPOFOUNDRY_ASKPASS_TOKEN" ;;'
|
||||
printf '%s\n' 'esac'
|
||||
} >"$askpass"
|
||||
chmod 700 "$askpass"
|
||||
# Not "cmd; rm": a failed fetch must still be reported to the caller.
|
||||
if ! GIT_ASKPASS="$askpass" REPOFOUNDRY_ASKPASS_USER="${STATE[gitea_login]}" \
|
||||
REPOFOUNDRY_ASKPASS_TOKEN="${CREDENTIALS[GITEA_TOKEN]}" \
|
||||
git_project "$dir" fetch -q origin; then
|
||||
rm -f -- "$askpass"
|
||||
return 1
|
||||
fi
|
||||
rm -f -- "$askpass"
|
||||
}
|
||||
+38
-1
@@ -4,7 +4,7 @@
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: is_reused, repo_owner, repo_url
|
||||
# Provides: is_reused, repo_owner, repo_url, gitea_host, origin_protocol, origin_url, github_remote_url, framework_url
|
||||
|
||||
# is_reused HOST: succeed if the existing repository on HOST will be reused.
|
||||
is_reused() {
|
||||
@@ -26,3 +26,40 @@ repo_url() {
|
||||
printf '%s/%s/%s' "${CONFIG[GITHUB_WEB_URL]}" "${PROJECT[github_owner]}" "${PROJECT[name]}"
|
||||
fi
|
||||
}
|
||||
|
||||
# gitea_host: the host name of the Gitea server, from GITEA_URL.
|
||||
gitea_host() {
|
||||
local host="${CONFIG[GITEA_URL]#https://}"
|
||||
host="${host%%/*}"
|
||||
printf '%s' "${host%%:*}"
|
||||
}
|
||||
|
||||
# origin_protocol: SSH when the SSH test passed, otherwise HTTPS.
|
||||
origin_protocol() {
|
||||
if ((${STATE[is_ssh_ok]:-0})); then
|
||||
printf 'SSH'
|
||||
else
|
||||
printf 'HTTPS'
|
||||
fi
|
||||
}
|
||||
|
||||
# origin_url: the address of the Gitea repository for the origin remote. It
|
||||
# never holds a credential: "git@" is the SSH user name, not a secret.
|
||||
origin_url() {
|
||||
if [[ $(origin_protocol) == SSH ]]; then
|
||||
printf 'ssh://git@%s:%s/%s/%s.git' "$(gitea_host)" "${CONFIG[GITEA_SSH_PORT]}" \
|
||||
"${PROJECT[gitea_owner]}" "${PROJECT[name]}"
|
||||
else
|
||||
printf '%s/%s/%s.git' "${CONFIG[GITEA_URL]}" "${PROJECT[gitea_owner]}" "${PROJECT[name]}"
|
||||
fi
|
||||
}
|
||||
|
||||
github_remote_url() {
|
||||
printf '%s/%s/%s.git' "${CONFIG[GITHUB_WEB_URL]}" "${PROJECT[github_owner]}" "${PROJECT[name]}"
|
||||
}
|
||||
|
||||
# framework_url: where the framework submodule comes from (always SSH).
|
||||
framework_url() {
|
||||
printf 'ssh://git@%s:%s/%s.git' "$(gitea_host)" "${CONFIG[GITEA_SSH_PORT]}" \
|
||||
"${CONFIG[FRAMEWORK_REPO]}"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# localproject.sh - The local project: its directory, its git repository and its remotes.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: inspect_local_directory, confirm_local_directory, ensure_remote, checkout_gitea_history, create_local_project
|
||||
|
||||
# inspect_local_directory: record in STATE[local_dir] whether the project
|
||||
# directory is missing, empty or not_empty. It creates nothing.
|
||||
inspect_local_directory() {
|
||||
local dir="${PROJECT[directory]}"
|
||||
if [[ ! -e $dir ]]; then
|
||||
STATE[local_dir]="missing"
|
||||
elif [[ ! -d $dir ]]; then
|
||||
die "$dir already exists and is not a directory"
|
||||
elif [[ -z "$(find "$dir" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
|
||||
STATE[local_dir]="empty"
|
||||
else
|
||||
STATE[local_dir]="not_empty"
|
||||
fi
|
||||
}
|
||||
|
||||
# confirm_local_directory: an existing directory is only used after a yes.
|
||||
confirm_local_directory() {
|
||||
local dir="${PROJECT[directory]}" what="is empty"
|
||||
if [[ ${STATE[local_dir]} == missing ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ ${STATE[local_dir]} == not_empty ]]; then
|
||||
what="already has files"
|
||||
fi
|
||||
prompt_yes_no "The directory $dir already exists and $what. Use it" n
|
||||
if ! ((REPLY)); then
|
||||
die "stopped: choose another directory or remove this one"
|
||||
fi
|
||||
}
|
||||
|
||||
# ensure_remote DIR NAME URL: add the remote, or accept one that already has
|
||||
# exactly this address. A different address is never overwritten.
|
||||
ensure_remote() {
|
||||
local dir="$1" name="$2" url="$3" existing
|
||||
# git config exits 1 when the remote is not set; that is the normal case.
|
||||
existing="$(git_project "$dir" config --get "remote.$name.url" || true)"
|
||||
if [[ -z $existing ]]; then
|
||||
git_project "$dir" remote add "$name" "$url"
|
||||
elif [[ $existing != "$url" ]]; then
|
||||
die "the remote '$name' in $dir already points to $existing; remove it or choose another directory"
|
||||
fi
|
||||
}
|
||||
|
||||
# checkout_gitea_history DIR: when the Gitea repository holds the license
|
||||
# commit, fetch it and start the local branch from it, so the local history
|
||||
# begins with that commit. Existing files are never overwritten: git refuses.
|
||||
checkout_gitea_history() {
|
||||
local dir="$1" err
|
||||
if ! fetch_origin "$dir" 2>/dev/null; then
|
||||
die "could not fetch the Gitea repository from $(origin_url); check your SSH key (or, over HTTPS, the token) and run the same command again"
|
||||
fi
|
||||
if ! git_project "$dir" rev-parse --verify -q refs/remotes/origin/main >/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
if git_project "$dir" rev-parse --verify -q HEAD >/dev/null 2>&1; then
|
||||
warn "$dir already has history: the Gitea content was fetched but not checked out"
|
||||
return 0
|
||||
fi
|
||||
make_temp_file
|
||||
err="$REPLY"
|
||||
if ! git_project "$dir" checkout -q -b main --track origin/main 2>"$err"; then
|
||||
die "git would overwrite files in $dir with the Gitea content; move them away and run again. Git said: $(head -n 2 "$err" | tr '\n' ' ')"
|
||||
fi
|
||||
}
|
||||
|
||||
# create_local_project: the directory, the git repository on main, the
|
||||
# remotes and, when GitHub is chosen, the license history. No commit is made.
|
||||
create_local_project() {
|
||||
local label="Local project" dir="${PROJECT[directory]}"
|
||||
begin_step "$label"
|
||||
mkdir -p -- "$dir"
|
||||
if [[ ! -e $dir/.git ]]; then
|
||||
git_project "$dir" init -q
|
||||
git_project "$dir" symbolic-ref HEAD "refs/heads/$DEFAULT_BRANCH"
|
||||
fi
|
||||
ensure_remote "$dir" origin "$(origin_url)"
|
||||
if ((PROJECT[has_github])); then
|
||||
ensure_remote "$dir" github "$(github_remote_url)"
|
||||
checkout_gitea_history "$dir"
|
||||
fi
|
||||
finish_step "$label" "created" "$dir (origin over $(origin_protocol))"
|
||||
}
|
||||
+20
-2
@@ -4,7 +4,17 @@
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: print_plan
|
||||
# Provides: local_plan_note, print_plan
|
||||
|
||||
# local_plan_note: what will happen to the project directory.
|
||||
local_plan_note() {
|
||||
local dir="${PROJECT[directory]}"
|
||||
case "${STATE[local_dir]}" in
|
||||
missing) printf 'create %s (new directory), git on %s, no commit' "$dir" "$DEFAULT_BRANCH" ;;
|
||||
empty) printf 'use the existing empty directory %s (you will be asked)' "$dir" ;;
|
||||
*) printf 'use the existing directory %s, which has files (you will be asked)' "$dir" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
print_plan() {
|
||||
local gitea_action github_action origin_note
|
||||
@@ -35,5 +45,13 @@ print_plan() {
|
||||
else
|
||||
origin_note="HTTPS (SSH test: ${STATE[ssh_note]})"
|
||||
fi
|
||||
say "$(printf ' %-18s: %s' "Local origin" "will use $origin_note, in a later phase")"
|
||||
say "$(printf ' %-18s: %s' "Local project" "$(local_plan_note)")"
|
||||
say "$(printf ' %-18s: %s' "Local origin" "will use $origin_note")"
|
||||
if ((STATE[is_ssh_ok])); then
|
||||
say "$(printf ' %-18s: %s' "Framework" "add $(framework_url) as a submodule")"
|
||||
say "$(printf ' %-18s: %s' "Skills and hooks" "install once; plan gate $(yes_no "${PROJECT[is_plan_gate_enabled]}")")"
|
||||
say "$(printf ' %-18s: %s' "Templates" "AGENTS.md and docs/artifact-registry.md (you are asked before a file is replaced)")"
|
||||
else
|
||||
say "$(printf ' %-18s: %s' "Framework" "NOT possible without SSH to Gitea; you will be asked whether to go on without it")"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -108,9 +108,7 @@ preflight_github() {
|
||||
# or without access it is simply "not passed"; it never stops the run.
|
||||
test_gitea_ssh() {
|
||||
local host port output status=0
|
||||
host="${CONFIG[GITEA_URL]#https://}"
|
||||
host="${host%%/*}"
|
||||
host="${host%%:*}"
|
||||
host="$(gitea_host)"
|
||||
port="${CONFIG[GITEA_SSH_PORT]}"
|
||||
STATE[is_ssh_ok]=0
|
||||
STATE[ssh_note]="failed (check your SSH key and that $host:$port is reachable)"
|
||||
@@ -163,5 +161,6 @@ run_preflight() {
|
||||
fi
|
||||
test_gitea_ssh
|
||||
decide_existing_repositories
|
||||
inspect_local_directory
|
||||
say "All checks passed."
|
||||
}
|
||||
|
||||
+3
-2
@@ -42,10 +42,11 @@ report_outcome() {
|
||||
say "$(printf ' %-18s: %s' "$label" "${STEP_STATUS[$label]}${STEP_DETAIL[$label]:+ ${STEP_DETAIL[$label]}}")"
|
||||
done
|
||||
if ((code == 0)); then
|
||||
say "The local project with the framework is created by a later phase."
|
||||
say "The project is in ${PROJECT[directory]}. Nothing was committed there: review it, then work on a branch."
|
||||
else
|
||||
say "To continue: fix the problem named above and run the same command again with --apply."
|
||||
say "A repository this run created is still empty (or holds only the license), so the next run offers to reuse it."
|
||||
say "Nothing is deleted automatically. To start over, delete the repositories above in the web interface."
|
||||
say "A directory, remotes and submodule created so far are used again by the next run; you are asked before an existing directory or file is touched."
|
||||
say "Nothing is deleted automatically. To start over, delete the repositories above in the web interface and the project directory by hand."
|
||||
fi
|
||||
}
|
||||
|
||||
+6
-1
@@ -4,7 +4,7 @@
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: is_valid_repo_name, is_valid_gitea_owner, is_valid_github_owner, is_valid_description, is_valid_directory, is_valid_base_url, is_valid_request_url, is_valid_token, is_valid_port, is_valid_interval, normalize_url
|
||||
# Provides: is_valid_framework_repo, is_valid_repo_name, is_valid_gitea_owner, is_valid_github_owner, is_valid_description, is_valid_directory, is_valid_base_url, is_valid_request_url, is_valid_token, is_valid_port, is_valid_interval, normalize_url
|
||||
|
||||
is_valid_repo_name() {
|
||||
local name="$1"
|
||||
@@ -49,6 +49,11 @@ is_valid_token() {
|
||||
[[ $1 =~ ^[A-Za-z0-9_.~+/=-]{8,255}$ ]]
|
||||
}
|
||||
|
||||
# OWNER/NAME of the framework repository on Gitea.
|
||||
is_valid_framework_repo() {
|
||||
[[ $1 =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ && $1 != */.. && $1 != ../* && $1 != ./* && $1 != */. ]]
|
||||
}
|
||||
|
||||
is_valid_port() {
|
||||
[[ $1 =~ ^[0-9]{1,5}$ ]] && ((10#$1 >= 1 && 10#$1 <= 65535))
|
||||
}
|
||||
|
||||
+65
-6
@@ -23,6 +23,7 @@ readonly FAKE_GITHUB_PAT="ghpFAKEtoken1234567890"
|
||||
readonly ANSWERS_GITHUB=$'my-app\nA test app\n\nTirSystem\ny\nacme-org\n\nn\n'
|
||||
readonly ANSWERS_GITEA_ONLY=$'my-app\n\n\nTirSystem\nn\n\nn\n'
|
||||
|
||||
SHARED_REMOTES=""
|
||||
TESTS_RUN=0
|
||||
TESTS_FAILED=0
|
||||
CURRENT_TEST=""
|
||||
@@ -91,6 +92,61 @@ assert_file_missing() {
|
||||
new_workdir() {
|
||||
WORK="$(mktemp -d "${TMPDIR:-/tmp}/repofoundry-test.XXXXXX")"
|
||||
mkdir -p "$WORK/bin" "$WORK/tmp"
|
||||
write_gitconfig
|
||||
}
|
||||
|
||||
# write_gitconfig: the git configuration every test run uses instead of the
|
||||
# real user's (GIT_CONFIG_GLOBAL), so no test depends on or changes it. The
|
||||
# remote addresses of Gitea and the framework are redirected to local bare
|
||||
# repositories (see setup_local_remotes); nothing reaches the network.
|
||||
write_gitconfig() {
|
||||
cat >"$WORK/gitconfig" <<EOF
|
||||
[user]
|
||||
name = Test User
|
||||
email = test@example.test
|
||||
[protocol "file"]
|
||||
allow = always
|
||||
[url "file://$WORK/remote/"]
|
||||
insteadOf = https://git.example.test/
|
||||
[url "file://$WORK/remote/"]
|
||||
insteadOf = ssh://git@git.example.test:10022/
|
||||
EOF
|
||||
}
|
||||
|
||||
# ensure_shared_remotes: build, once per run of the suite, the local bare
|
||||
# repositories that stand in for Gitea (TirSystem/my-app.git, holding the
|
||||
# license commit) and for the framework (a copy of the real one).
|
||||
ensure_shared_remotes() {
|
||||
if [[ -n $SHARED_REMOTES && -d $SHARED_REMOTES ]]; then
|
||||
return 0
|
||||
fi
|
||||
SHARED_REMOTES="$(mktemp -d "${TMPDIR:-/tmp}/repofoundry-remotes.XXXXXX")"
|
||||
mkdir -p "$SHARED_REMOTES/TirSystem"
|
||||
git clone -q --bare "$REPO_ROOT/framework" "$SHARED_REMOTES/TirSystem/SQA-QC-Framework.git"
|
||||
git init -q --bare "$SHARED_REMOTES/TirSystem/my-app.git"
|
||||
git init -q "$SHARED_REMOTES/seed"
|
||||
git -C "$SHARED_REMOTES/seed" symbolic-ref HEAD refs/heads/main
|
||||
printf 'GNU AFFERO GENERAL PUBLIC LICENSE (test copy)\n' >"$SHARED_REMOTES/seed/LICENSE"
|
||||
git -C "$SHARED_REMOTES/seed" add LICENSE
|
||||
git -c user.name=Seed -c user.email=seed@example.test -C "$SHARED_REMOTES/seed" commit -q -m "Initial commit"
|
||||
git -C "$SHARED_REMOTES/seed" push -q "$SHARED_REMOTES/TirSystem/my-app.git" main
|
||||
find "$SHARED_REMOTES/seed" \( -type f -o -type l \) -delete
|
||||
find "$SHARED_REMOTES/seed" -depth -type d -exec rmdir {} +
|
||||
}
|
||||
|
||||
# remove_shared_remotes: delete the shared repositories at the end of the run.
|
||||
remove_shared_remotes() {
|
||||
if [[ -n $SHARED_REMOTES && -d $SHARED_REMOTES ]]; then
|
||||
find "$SHARED_REMOTES" \( -type f -o -type l \) -delete
|
||||
find "$SHARED_REMOTES" -depth -type d -exec rmdir {} +
|
||||
fi
|
||||
SHARED_REMOTES=""
|
||||
}
|
||||
|
||||
# setup_local_remotes: this test's own copy of the local remotes.
|
||||
setup_local_remotes() {
|
||||
ensure_shared_remotes
|
||||
cp -R "$SHARED_REMOTES" "$WORK/remote"
|
||||
}
|
||||
|
||||
# remove_workdir: delete the work directory without a recursive rm: files
|
||||
@@ -245,6 +301,7 @@ setup_hosts() {
|
||||
write_curl_stub
|
||||
write_ssh_stub 0
|
||||
write_happy_routes
|
||||
setup_local_remotes
|
||||
}
|
||||
|
||||
# calls: the "METHOD URL" lines the stub curl received (empty if none).
|
||||
@@ -260,9 +317,11 @@ run_cli() {
|
||||
local input="$1"
|
||||
shift
|
||||
STATUS=0
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
||||
REPOFOUNDRY_SYNC_WAIT=0 \
|
||||
"$BASH" "$SCRIPT" "$@" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
|
||||
# Run inside the work directory: a relative project directory such as
|
||||
# ./my-app is then created there, never in the repository.
|
||||
(cd "$WORK" && PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
||||
REPOFOUNDRY_SYNC_WAIT=0 GIT_CONFIG_GLOBAL="$WORK/gitconfig" GIT_CONFIG_NOSYSTEM=1 \
|
||||
"$BASH" "$SCRIPT" "$@" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt") ||
|
||||
STATUS=$?
|
||||
OUT="$(cat "$WORK/out.txt")"
|
||||
ERR="$(cat "$WORK/err.txt")"
|
||||
@@ -277,9 +336,9 @@ run_lib() {
|
||||
printf '#!/usr/bin/env bash\nsource "%s"\n' "$SCRIPT"
|
||||
printf '%s\n' "$2"
|
||||
} >"$WORK/snippet.sh"
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
||||
REPOFOUNDRY_SYNC_WAIT=0 \
|
||||
"$BASH" "$WORK/snippet.sh" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
|
||||
(cd "$WORK" && PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
||||
REPOFOUNDRY_SYNC_WAIT=0 GIT_CONFIG_GLOBAL="$WORK/gitconfig" GIT_CONFIG_NOSYSTEM=1 \
|
||||
"$BASH" "$WORK/snippet.sh" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt") ||
|
||||
STATUS=$?
|
||||
OUT="$(cat "$WORK/out.txt")"
|
||||
ERR="$(cat "$WORK/err.txt")"
|
||||
|
||||
@@ -68,6 +68,7 @@ for test_file in "$TEST_DIR"/test-*.sh; do
|
||||
done < <(declare -F | awk '$3 ~ /^test_/ {print $3}')
|
||||
done
|
||||
|
||||
remove_shared_remotes
|
||||
printf '\n%d checks, %d failed, %d static check(s) failed\n' \
|
||||
"$TESTS_RUN" "$TESTS_FAILED" "$failed_checks"
|
||||
if ((TESTS_FAILED > 0 || failed_checks > 0)); then
|
||||
|
||||
@@ -264,6 +264,9 @@ test_apply_declined_creates_nothing() {
|
||||
|
||||
test_apply_for_user_owners_uses_the_user_endpoints() {
|
||||
setup_hosts
|
||||
# The Gitea account's own repository (with the license commit) is a copy.
|
||||
mkdir -p "$WORK/remote/gitea-user"
|
||||
cp -R "$WORK/remote/TirSystem/my-app.git" "$WORK/remote/gitea-user/my-app.git"
|
||||
write_routes <<'ROUTES'
|
||||
GET|/api/v1/user|200|{"login":"gitea-user"}
|
||||
GET|/api/v1/licenses|200|[{"key":"AGPL-3.0"}]
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-local.sh - tests for the local project (MIL-003): the directory, the
|
||||
# git repository and its remotes, the framework submodule, skills, hooks and
|
||||
# plan gate, and the templates. Real git runs here, against local bare
|
||||
# repositories that stand in for Gitea and the framework (git rewrites the
|
||||
# remote addresses, see write_gitconfig); only the two hosts' APIs are stubs.
|
||||
# Sourced by run-tests.sh.
|
||||
|
||||
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
|
||||
|
||||
# local_answers DIR GITHUB GATE: the prompt answers; the result is in
|
||||
# LOCAL_ANSWERS (kept in a variable because $(...) would drop the last newline).
|
||||
local_answers() {
|
||||
if [[ $2 == y ]]; then
|
||||
printf -v LOCAL_ANSWERS 'my-app\nA test app\n\nTirSystem\ny\nacme-org\n%s\n%s\n' "$1" "$3"
|
||||
else
|
||||
printf -v LOCAL_ANSWERS 'my-app\n\n\nTirSystem\nn\n%s\n%s\n' "$1" "$3"
|
||||
fi
|
||||
}
|
||||
|
||||
# project_git DIR ARGS...: git in the project with the tests' own config.
|
||||
project_git() {
|
||||
local dir="$1"
|
||||
shift
|
||||
GIT_CONFIG_GLOBAL="$WORK/gitconfig" GIT_CONFIG_NOSYSTEM=1 git -C "$dir" "$@"
|
||||
}
|
||||
|
||||
# files_with_secret DIR: any file below DIR (the .git folder included) that
|
||||
# holds one of the fake tokens.
|
||||
files_with_secret() {
|
||||
grep -rlF -e "$FAKE_GITEA_TOKEN" -e "$FAKE_GITHUB_PAT" "$1" 2>/dev/null || true
|
||||
}
|
||||
|
||||
readonly SSH_FRAMEWORK_URL="ssh://git@git.example.test:10022/TirSystem/SQA-QC-Framework.git"
|
||||
|
||||
# ----------------------------------------------------- directory and remotes
|
||||
|
||||
test_local_project_gets_credential_free_remotes_and_the_license_history() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_file_exists "directory created" "$dir/.git"
|
||||
assert_eq "branch is main" "main" "$(project_git "$dir" symbolic-ref --short HEAD)"
|
||||
assert_eq "origin over SSH, no credential" "ssh://git@git.example.test:10022/TirSystem/my-app.git" "$(project_git "$dir" config --get remote.origin.url)"
|
||||
assert_eq "github remote over HTTPS, no credential" "https://github.com/acme-org/my-app.git" "$(project_git "$dir" config --get remote.github.url)"
|
||||
assert_eq "the license commit is the whole history" "1" "$(project_git "$dir" rev-list --count HEAD)"
|
||||
assert_eq "it is the Gitea commit" "Initial commit" "$(project_git "$dir" log -1 --format=%s)"
|
||||
assert_file_exists "LICENSE from Gitea" "$dir/LICENSE"
|
||||
assert_eq "branch follows origin" "origin" "$(project_git "$dir" config --get branch.main.remote)"
|
||||
assert_eq "no token in any file of the project" "" "$(files_with_secret "$dir")"
|
||||
assert_contains "reported" "$OUT" "Local project : created $dir (origin over SSH)"
|
||||
}
|
||||
|
||||
test_gitea_only_project_has_no_github_remote_and_no_commit() {
|
||||
setup_hosts
|
||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||
local dir="$WORK/project"
|
||||
local_answers "$dir" n n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_eq "origin" "ssh://git@git.example.test:10022/TirSystem/my-app.git" "$(project_git "$dir" config --get remote.origin.url)"
|
||||
assert_eq "no github remote" "" "$(project_git "$dir" config --get remote.github.url || true)"
|
||||
assert_file_missing "no license file" "$dir/LICENSE"
|
||||
assert_eq "no commit was made" "0" "$(project_git "$dir" rev-list --all --count)"
|
||||
assert_eq "no token in any file" "" "$(files_with_secret "$dir")"
|
||||
}
|
||||
|
||||
test_existing_directory_is_used_only_after_a_yes() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
mkdir -p "$dir"
|
||||
printf 'mine\n' >"$dir/keep.txt"
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\nn\n'
|
||||
assert_status "answered no" 1 "$STATUS"
|
||||
assert_contains "asked" "$ERR" "The directory $dir already exists and already has files. Use it"
|
||||
assert_contains "stopped" "$ERR" "stopped: choose another directory or remove this one"
|
||||
assert_file_missing "nothing added to the directory" "$dir/.git"
|
||||
assert_not_contains "no repository created before the answer" "$(calls)" "POST"
|
||||
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
|
||||
assert_status "answered yes" 0 "$STATUS"
|
||||
assert_eq "the existing file is untouched" "mine" "$(cat "$dir/keep.txt")"
|
||||
assert_file_exists "project created beside it" "$dir/.git"
|
||||
}
|
||||
|
||||
test_an_empty_existing_directory_is_also_confirmed() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
mkdir -p "$dir"
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
|
||||
assert_status "empty directory, yes" 0 "$STATUS"
|
||||
assert_contains "asked" "$ERR" "already exists and is empty. Use it"
|
||||
}
|
||||
|
||||
test_a_file_that_would_be_overwritten_by_the_license_history_is_kept() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
mkdir -p "$dir"
|
||||
printf 'my own license\n' >"$dir/LICENSE"
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
|
||||
assert_status "git refuses to overwrite" 1 "$STATUS"
|
||||
assert_contains "says why" "$ERR" "git would overwrite files in $dir"
|
||||
assert_eq "the file is intact" "my own license" "$(cat "$dir/LICENSE")"
|
||||
assert_contains "step failed" "$OUT" "Local project : FAILED"
|
||||
assert_contains "later steps not attempted" "$OUT" "Framework : not attempted"
|
||||
}
|
||||
|
||||
test_a_remote_with_another_address_is_never_replaced() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
mkdir -p "$dir"
|
||||
project_git "$dir" init -q
|
||||
project_git "$dir" remote add origin https://elsewhere.example.test/x/y.git
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
|
||||
assert_status "different origin" 1 "$STATUS"
|
||||
assert_contains "says so" "$ERR" "the remote 'origin' in $dir already points to https://elsewhere.example.test/x/y.git"
|
||||
assert_eq "origin unchanged" "https://elsewhere.example.test/x/y.git" "$(project_git "$dir" config --get remote.origin.url)"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- framework
|
||||
|
||||
test_framework_is_a_submodule_and_installed_in_order() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_eq "submodule address" "$SSH_FRAMEWORK_URL" "$(project_git "$dir" config -f .gitmodules --get submodule.framework.url)"
|
||||
assert_file_exists "submodule content" "$dir/framework/scripts/install-skills.sh"
|
||||
assert_file_exists "skills installed" "$dir/.claude/skills/coding-conventions/SKILL.md"
|
||||
assert_file_exists "skills for the other harness" "$dir/.agents/skills/.framework-skills"
|
||||
assert_eq "hooks path" "framework/githooks" "$(project_git "$dir" config --local --get core.hooksPath)"
|
||||
assert_eq "plan gate off" "" "$(project_git "$dir" config --local --get planGate.enabled || true)"
|
||||
assert_contains "reported" "$OUT" "Framework : created $SSH_FRAMEWORK_URL"
|
||||
assert_contains "reported once" "$OUT" "Skills and hooks : created (skills installed; hooks installed; plan gate off)"
|
||||
}
|
||||
|
||||
test_skills_and_hooks_are_installed_once() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||
assert_status "first run" 0 "$STATUS"
|
||||
run_lib "" "PROJECT[directory]='$dir'
|
||||
PROJECT[is_plan_gate_enabled]=0
|
||||
install_framework
|
||||
echo \"\${STATE[skills_note]}|\${STATE[hooks_note]}\""
|
||||
assert_status "second pass" 0 "$STATUS"
|
||||
assert_eq "nothing installed twice" "skills already installed|hooks already installed" "$OUT"
|
||||
}
|
||||
|
||||
test_the_plan_gate_is_optional_and_refuses_unplanned_commits() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project" out
|
||||
local_answers "$dir" y y
|
||||
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||
assert_status "apply with the plan gate" 0 "$STATUS"
|
||||
assert_eq "plan gate on" "true" "$(project_git "$dir" config --local --get planGate.enabled)"
|
||||
assert_contains "reported" "$OUT" "plan gate on)"
|
||||
# The hooks refuse a commit on main, so work on a branch.
|
||||
project_git "$dir" checkout -q -b work
|
||||
mkdir -p "$dir/src"
|
||||
printf 'x\n' >"$dir/src/x.txt"
|
||||
project_git "$dir" add src/x.txt
|
||||
out="$(project_git "$dir" commit -q -m "unplanned change" 2>&1 || true)"
|
||||
assert_contains "refused without a task trailer" "$out" "plan-first gate: no 'Task: MIL-NNN#N' trailer"
|
||||
assert_eq "no commit was made" "1" "$(project_git "$dir" rev-list --count HEAD)"
|
||||
}
|
||||
|
||||
test_without_the_plan_gate_the_same_commit_is_allowed() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||
project_git "$dir" checkout -q -b work
|
||||
mkdir -p "$dir/src"
|
||||
printf 'x\n' >"$dir/src/x.txt"
|
||||
project_git "$dir" add src/x.txt
|
||||
project_git "$dir" commit -q -m "change"
|
||||
assert_eq "commit made" "2" "$(project_git "$dir" rev-list --count HEAD)"
|
||||
}
|
||||
|
||||
test_the_hooks_refuse_a_commit_on_main() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project" out
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||
printf 'x\n' >"$dir/x.txt"
|
||||
project_git "$dir" add x.txt
|
||||
out="$(project_git "$dir" commit -q -m "on main" 2>&1 || true)"
|
||||
assert_contains "refused on main" "$out" "refusing to commit directly on 'main'"
|
||||
}
|
||||
|
||||
test_an_existing_hooks_path_is_not_replaced_without_a_yes() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
mkdir -p "$dir"
|
||||
project_git "$dir" init -q
|
||||
project_git "$dir" config core.hooksPath .githooks
|
||||
local_answers "$dir" y y
|
||||
# create now, use the directory, keep the hooks path
|
||||
run_apply "$LOCAL_ANSWERS"$'y\ny\nn\n'
|
||||
assert_status "run continues" 0 "$STATUS"
|
||||
assert_contains "asked" "$ERR" "core.hooksPath is already '.githooks'. Replace it with framework/githooks"
|
||||
assert_eq "hooks path kept" ".githooks" "$(project_git "$dir" config --local --get core.hooksPath)"
|
||||
assert_contains "reported" "$OUT" "kept the existing hooks path '.githooks'"
|
||||
assert_contains "the plan gate is not on without the hooks" "$ERR" "the plan gate is not enabled because the framework hooks were not installed"
|
||||
# Answering yes replaces it.
|
||||
run_apply "$LOCAL_ANSWERS"$'y\ny\ny\n'
|
||||
assert_eq "hooks path replaced after a yes" "framework/githooks" "$(project_git "$dir" config --local --get core.hooksPath)"
|
||||
}
|
||||
|
||||
test_a_global_hooks_path_is_reported_not_changed() {
|
||||
setup_hosts
|
||||
git config --file "$WORK/gitconfig" core.hooksPath /somewhere/global-hooks
|
||||
local dir="$WORK/project"
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_contains "warns" "$ERR" "your global core.hooksPath is '/somewhere/global-hooks'"
|
||||
assert_eq "the global setting is untouched" "/somewhere/global-hooks" "$(git config --file "$WORK/gitconfig" --get core.hooksPath)"
|
||||
assert_eq "the project has its own" "framework/githooks" "$(project_git "$dir" config --local --get core.hooksPath)"
|
||||
}
|
||||
|
||||
test_a_project_root_in_the_environment_cannot_redirect_the_framework_scripts() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
local_answers "$dir" y n
|
||||
PROJECT_ROOT="$WORK/elsewhere" run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_file_missing "nothing installed where the environment pointed" "$WORK/elsewhere"
|
||||
assert_file_exists "installed in the project" "$dir/.claude/skills/.framework-skills"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- templates
|
||||
|
||||
test_templates_are_copied() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_eq "AGENTS.md is the template" "$(cat "$dir/framework/templates/AGENTS-template.md")" "$(cat "$dir/AGENTS.md")"
|
||||
assert_eq "registry is the template" "$(cat "$dir/framework/templates/artifact-registry-template.md")" "$(cat "$dir/docs/artifact-registry.md")"
|
||||
assert_contains "reported" "$OUT" "Templates : created (copied AGENTS.md; copied docs/artifact-registry.md)"
|
||||
}
|
||||
|
||||
test_existing_template_targets_are_replaced_only_after_a_yes() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
mkdir -p "$dir/docs"
|
||||
printf 'my agents file\n' >"$dir/AGENTS.md"
|
||||
printf 'my registry\n' >"$dir/docs/artifact-registry.md"
|
||||
local_answers "$dir" y n
|
||||
# create now, use the directory, keep AGENTS.md, replace the registry
|
||||
run_apply "$LOCAL_ANSWERS"$'y\ny\nn\ny\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_contains "asked about AGENTS.md" "$ERR" "AGENTS.md already exists. Replace it with the framework template"
|
||||
assert_eq "AGENTS.md kept" "my agents file" "$(cat "$dir/AGENTS.md")"
|
||||
assert_eq "registry replaced after a yes" "$(cat "$dir/framework/templates/artifact-registry-template.md")" "$(cat "$dir/docs/artifact-registry.md")"
|
||||
assert_contains "reported" "$OUT" "kept existing AGENTS.md; copied docs/artifact-registry.md"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------- SSH and errors
|
||||
|
||||
test_without_ssh_the_run_stops_unless_the_framework_is_skipped() {
|
||||
setup_hosts
|
||||
write_ssh_stub 255
|
||||
local dir="$WORK/project"
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\nn\n'
|
||||
assert_status "answered no" 1 "$STATUS"
|
||||
assert_contains "explains" "$ERR" "SSH to Gitea (git.example.test port 10022) did not work, so the framework cannot be added"
|
||||
assert_contains "says what to do" "$ERR" "stopped: set up SSH access to Gitea (see the README) and run again"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
assert_file_missing "no directory" "$dir"
|
||||
}
|
||||
|
||||
test_without_ssh_the_framework_steps_are_skipped_after_a_yes() {
|
||||
setup_hosts
|
||||
write_ssh_stub 255
|
||||
local dir="$WORK/project"
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\ny\n'
|
||||
assert_status "continue without the framework" 0 "$STATUS"
|
||||
assert_eq "origin over HTTPS, no credential" "https://git.example.test/TirSystem/my-app.git" "$(project_git "$dir" config --get remote.origin.url)"
|
||||
assert_eq "the license history arrived over HTTPS" "1" "$(project_git "$dir" rev-list --count HEAD)"
|
||||
assert_eq "no token in any file" "" "$(files_with_secret "$dir")"
|
||||
assert_eq "no temporary files left" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||
assert_contains "framework skipped" "$OUT" "Framework : skipped (no SSH access to Gitea)"
|
||||
assert_contains "skills and hooks skipped" "$OUT" "Skills and hooks : skipped (no SSH access to Gitea)"
|
||||
assert_contains "templates skipped" "$OUT" "Templates : skipped (no SSH access to Gitea)"
|
||||
assert_file_missing "no submodule" "$dir/.gitmodules"
|
||||
assert_file_missing "no AGENTS.md" "$dir/AGENTS.md"
|
||||
}
|
||||
|
||||
test_a_failed_submodule_gives_an_actionable_message() {
|
||||
setup_hosts
|
||||
find "$WORK/remote/TirSystem/SQA-QC-Framework.git" \( -type f -o -type l \) -delete
|
||||
find "$WORK/remote/TirSystem/SQA-QC-Framework.git" -depth -type d -exec rmdir {} +
|
||||
local dir="$WORK/project"
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||
assert_status "no framework repository" 1 "$STATUS"
|
||||
assert_contains "names the address" "$ERR" "git could not add the framework from $SSH_FRAMEWORK_URL"
|
||||
assert_contains "says how to test the access" "$ERR" "ssh -p 10022 -T git@git.example.test"
|
||||
assert_contains "step failed" "$OUT" "Framework : FAILED"
|
||||
assert_contains "the rest not attempted" "$OUT" "Skills and hooks : not attempted"
|
||||
assert_contains "the project itself exists" "$OUT" "Local project : created"
|
||||
}
|
||||
|
||||
test_the_framework_repository_is_configurable() {
|
||||
setup_hosts
|
||||
mkdir -p "$WORK/remote/Other"
|
||||
cp -R "$WORK/remote/TirSystem/SQA-QC-Framework.git" "$WORK/remote/Other/Framework.git"
|
||||
printf 'FRAMEWORK_REPO=Other/Framework\n' >>"$WORK/config.env"
|
||||
local dir="$WORK/project"
|
||||
local_answers "$dir" y n
|
||||
run_apply "$LOCAL_ANSWERS"$'y\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_eq "submodule address" "ssh://git@git.example.test:10022/Other/Framework.git" "$(project_git "$dir" config -f .gitmodules --get submodule.framework.url)"
|
||||
}
|
||||
|
||||
test_framework_repo_must_look_like_owner_and_name() {
|
||||
local value
|
||||
for value in "nope" "a/b/c" "../x" "a/.." "a b/c" "/x"; do
|
||||
printf 'GITEA_URL=https://git.example.test\nFRAMEWORK_REPO=%s\n' "$value" >"$WORK/c.env"
|
||||
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
|
||||
validate_config"
|
||||
assert_status "FRAMEWORK_REPO=$value" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "FRAMEWORK_REPO"
|
||||
done
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- the plan
|
||||
|
||||
test_the_dry_run_plan_describes_the_local_steps_and_creates_nothing() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
local_answers "$dir" y y
|
||||
run_dry "$LOCAL_ANSWERS"
|
||||
assert_status "dry run" 0 "$STATUS"
|
||||
assert_contains "project" "$OUT" "Local project : create $dir (new directory), git on main, no commit"
|
||||
assert_contains "framework" "$OUT" "Framework : add $SSH_FRAMEWORK_URL as a submodule"
|
||||
assert_contains "skills and hooks" "$OUT" "Skills and hooks : install once; plan gate yes"
|
||||
assert_contains "templates" "$OUT" "Templates : AGENTS.md and docs/artifact-registry.md (you are asked before a file is replaced)"
|
||||
assert_file_missing "nothing created" "$dir"
|
||||
}
|
||||
|
||||
test_the_plan_marks_an_existing_directory_and_missing_ssh() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
mkdir -p "$dir"
|
||||
printf 'x\n' >"$dir/a.txt"
|
||||
write_ssh_stub 255
|
||||
local_answers "$dir" y n
|
||||
run_dry "$LOCAL_ANSWERS"
|
||||
assert_contains "existing directory" "$OUT" "use the existing directory $dir, which has files (you will be asked)"
|
||||
assert_contains "no SSH" "$OUT" "NOT possible without SSH to Gitea; you will be asked whether to go on without it"
|
||||
assert_contains "HTTPS origin" "$OUT" "will use HTTPS (SSH test: failed"
|
||||
}
|
||||
|
||||
test_a_project_path_that_is_a_file_is_refused_in_the_preflight() {
|
||||
setup_hosts
|
||||
local dir="$WORK/project"
|
||||
printf 'x\n' >"$dir"
|
||||
local_answers "$dir" y n
|
||||
run_dry "$LOCAL_ANSWERS"
|
||||
assert_status "path is a file" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "already exists and is not a directory"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
test_remote_addresses_are_built_from_the_configuration() {
|
||||
run_lib "" 'CONFIG[GITEA_URL]=https://git.example.test/sub
|
||||
CONFIG[GITEA_SSH_PORT]=2222 CONFIG[FRAMEWORK_REPO]=Org/Fw CONFIG[GITHUB_WEB_URL]=https://github.com
|
||||
PROJECT[gitea_owner]=TirSystem PROJECT[github_owner]=acme PROJECT[name]=my-app
|
||||
STATE[is_ssh_ok]=1
|
||||
origin_url; echo
|
||||
STATE[is_ssh_ok]=0
|
||||
origin_url; echo
|
||||
github_remote_url; echo
|
||||
framework_url; echo
|
||||
gitea_host; echo'
|
||||
assert_eq "addresses" $'ssh://git@git.example.test:2222/TirSystem/my-app.git\nhttps://git.example.test/sub/TirSystem/my-app.git\nhttps://github.com/acme/my-app.git\nssh://git@git.example.test:2222/Org/Fw.git\ngit.example.test' "$OUT"
|
||||
}
|
||||
|
||||
test_the_https_fetch_hands_the_token_over_through_the_environment_only() {
|
||||
# A stub git plays the part of the server asking for credentials: it runs
|
||||
# the GIT_ASKPASS helper the way git does and records the answers.
|
||||
local real_git
|
||||
real_git="$(command -v git)"
|
||||
write_stub git "
|
||||
if [[ \$* == *'config --get remote.origin.url'* ]]; then echo https://git.example.test/TirSystem/my-app.git; exit 0; fi
|
||||
if [[ \$* == *fetch* ]]; then
|
||||
printf '%s\n' \"\$@\" >>\"\$STUB_DIR/git.args\"
|
||||
\"\$GIT_ASKPASS\" 'Username for https://git.example.test: ' >>\"\$STUB_DIR/askpass.out\"
|
||||
\"\$GIT_ASKPASS\" 'Password for https://git.example.test: ' >>\"\$STUB_DIR/askpass.out\"
|
||||
exit 0
|
||||
fi
|
||||
exec '$real_git' \"\$@\""
|
||||
run_lib "" "setup_temp_dir
|
||||
STATE[gitea_login]=gitea-user
|
||||
CREDENTIALS[GITEA_TOKEN]='$FAKE_GITEA_TOKEN'
|
||||
fetch_origin '$WORK'
|
||||
cleanup"
|
||||
assert_status "fetch" 0 "$STATUS"
|
||||
assert_eq "user name and token reach git" $'gitea-user\n'"$FAKE_GITEA_TOKEN" "$(cat "$WORK/askpass.out")"
|
||||
assert_not_contains "token not on the git command line" "$(cat "$WORK/git.args")" "$FAKE_GITEA_TOKEN"
|
||||
assert_eq "the helper is removed" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||
}
|
||||
|
||||
test_a_failed_https_fetch_is_reported_to_the_caller() {
|
||||
write_stub git "
|
||||
if [[ \$* == *'config --get remote.origin.url'* ]]; then echo https://git.example.test/TirSystem/my-app.git; exit 0; fi
|
||||
if [[ \$* == *fetch* ]]; then exit 128; fi
|
||||
exit 0"
|
||||
run_lib "" "setup_temp_dir
|
||||
STATE[gitea_login]=gitea-user
|
||||
CREDENTIALS[GITEA_TOKEN]='$FAKE_GITEA_TOKEN'
|
||||
if fetch_origin '$WORK'; then echo ok; else echo failed; fi
|
||||
cleanup"
|
||||
assert_eq "failure passed on" "failed" "$OUT"
|
||||
assert_eq "the helper is removed" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||
}
|
||||
@@ -103,7 +103,7 @@ test_usage_errors() {
|
||||
assert_contains "help shows exit codes" "$OUT" "Exit codes"
|
||||
run_cli "" --version
|
||||
assert_status "version" 0 "$STATUS"
|
||||
assert_contains "version output" "$OUT" "RepoFoundry 0.2.0"
|
||||
assert_contains "version output" "$OUT" "RepoFoundry 0.3.0"
|
||||
}
|
||||
|
||||
test_warns_when_env_is_not_ignored_by_git() {
|
||||
|
||||
Reference in New Issue
Block a user