From 0030334e5e9d6b5f746234ac68daac352129f514 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Mon, 5 Oct 2026 15:54:02 +0800 Subject: [PATCH] Add the local project, framework submodule, hooks and templates After the repositories and the mirror, the script now creates the local project: the directory, git on main, credential-free origin (and github) remotes, and the license history from Gitea. Then it adds the framework as a submodule over SSH, installs its skills and git hooks once (plan gate optional), and copies the AGENTS.md and artifact registry templates. Nothing is overwritten without a yes: an existing directory, core.hooksPath, AGENTS.md or docs/artifact-registry.md each ask first (default no). Without SSH the framework steps can only be skipped, after a yes. No commit is made in the new project. New optional config key FRAMEWORK_REPO. New library files git.sh, localproject.sh and framework.sh. Tests run real git against local bare repositories that stand in for Gitea and the framework, with a private git configuration (769 checks). The README is now the full guide. Task: MIL-003#1 Task: MIL-003#2 Task: MIL-003#3 Task: MIL-003#4 Task: MIL-003#5 Refs #15 Refs #16 Refs #17 Refs #18 Refs #19 Co-Authored-By: Claude Sonnet 5.5 --- README.md | 299 ++++++++++++++++++++++++---- config.env.example | 5 + src/create-project.sh | 38 ++-- src/lib/apply.sh | 23 ++- src/lib/config.sh | 3 + src/lib/constants.sh | 8 +- src/lib/framework.sh | 153 ++++++++++++++ src/lib/git.sh | 48 +++++ src/lib/hosts.sh | 39 +++- src/lib/localproject.sh | 90 +++++++++ src/lib/plan.sh | 22 +- src/lib/preflight.sh | 5 +- src/lib/steps.sh | 5 +- src/lib/validate.sh | 7 +- tests/lib.sh | 71 ++++++- tests/run-tests.sh | 1 + tests/test-hosts.sh | 3 + tests/test-local.sh | 431 ++++++++++++++++++++++++++++++++++++++++ tests/test-security.sh | 2 +- 19 files changed, 1185 insertions(+), 68 deletions(-) create mode 100644 src/lib/framework.sh create mode 100644 src/lib/git.sh create mode 100644 src/lib/localproject.sh create mode 100644 tests/test-local.sh diff --git a/README.md b/README.md index 66d243d..dbb47c4 100644 --- a/README.md +++ b/README.md @@ -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@: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). diff --git a/config.env.example b/config.env.example index fe695f8..2b8f361 100644 --- a/config.env.example +++ b/config.env.example @@ -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 diff --git a/src/create-project.sh b/src/create-project.sh index e3eb222..599c99d 100644 --- a/src/create-project.sh +++ b/src/create-project.sh @@ -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 diff --git a/src/lib/apply.sh b/src/lib/apply.sh index 0d6a4c7..124e11e 100644 --- a/src/lib/apply.sh +++ b/src/lib/apply.sh @@ -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 } diff --git a/src/lib/config.sh b/src/lib/config.sh index 898b0eb..f454da7 100644 --- a/src/lib/config.sh +++ b/src/lib/config.sh @@ -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() { diff --git a/src/lib/constants.sh b/src/lib/constants.sh index 62fe745..decf709 100644 --- a/src/lib/constants.sh +++ b/src/lib/constants.sh @@ -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" diff --git a/src/lib/framework.sh b/src/lib/framework.sh new file mode 100644 index 0000000..ab1098c --- /dev/null +++ b/src/lib/framework.sh @@ -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)" +} diff --git a/src/lib/git.sh b/src/lib/git.sh new file mode 100644 index 0000000..e158fd9 --- /dev/null +++ b/src/lib/git.sh @@ -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" +} diff --git a/src/lib/hosts.sh b/src/lib/hosts.sh index 6d77568..7fcae2d 100644 --- a/src/lib/hosts.sh +++ b/src/lib/hosts.sh @@ -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]}" +} diff --git a/src/lib/localproject.sh b/src/lib/localproject.sh new file mode 100644 index 0000000..231809c --- /dev/null +++ b/src/lib/localproject.sh @@ -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))" +} diff --git a/src/lib/plan.sh b/src/lib/plan.sh index 7d65b7a..f735c6b 100644 --- a/src/lib/plan.sh +++ b/src/lib/plan.sh @@ -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 } diff --git a/src/lib/preflight.sh b/src/lib/preflight.sh index 3ada395..5d6e751 100644 --- a/src/lib/preflight.sh +++ b/src/lib/preflight.sh @@ -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." } diff --git a/src/lib/steps.sh b/src/lib/steps.sh index 18caf35..351eadb 100644 --- a/src/lib/steps.sh +++ b/src/lib/steps.sh @@ -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 } diff --git a/src/lib/validate.sh b/src/lib/validate.sh index 45ae1de..7902f07 100644 --- a/src/lib/validate.sh +++ b/src/lib/validate.sh @@ -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)) } diff --git a/tests/lib.sh b/tests/lib.sh index b85d3b4..05252c4 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -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" <"$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")" diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 82ebac8..b0bcdd4 100644 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -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 diff --git a/tests/test-hosts.sh b/tests/test-hosts.sh index b3dc691..66f0808 100644 --- a/tests/test-hosts.sh +++ b/tests/test-hosts.sh @@ -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"}] diff --git a/tests/test-local.sh b/tests/test-local.sh new file mode 100644 index 0000000..2f36833 --- /dev/null +++ b/tests/test-local.sh @@ -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)" +} diff --git a/tests/test-security.sh b/tests/test-security.sh index 5883654..9ad23a0 100644 --- a/tests/test-security.sh +++ b/tests/test-security.sh @@ -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() {