diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4bf0690..6cfc284 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -15,9 +15,9 @@ The project especially values: - accessibility and native-editor UI improvements; - concise documentation and diagnostics. -Discuss large features or architecture changes in an issue before investing in -an implementation. The project intentionally keeps a narrow submodule-only -scope. +Discuss large features or architecture changes in an issue before starting +work. The project stays focused on Git-hosted UPM packages managed through +Unity's native Package Manager; it is not a general-purpose Git client. ## Development Prerequisites @@ -72,22 +72,25 @@ behavior. - Keep runtime code out of the package; this is an editor-only tool. - Preserve Windows, macOS, and Linux behavior. - Never evaluate user input through a shell. -- Keep mutations restricted to direct `Packages/com.author.package` paths. +- Restrict submodule filesystem changes to validated direct + `Packages/` children, and guard UPM manifest changes + independently. - Do not store credentials or run installers without the user's explicit confirmation. - Avoid implicit network or repository mutations during editor startup. -- Prefer explicit state, actionable errors, and rollback over optimistic UI. +- When a mutation fails, roll back only state the package owns, preserve + recovery evidence otherwise, and explain the next safe action. - Keep public APIs minimal; most implementation types should remain `internal`. - Add tests for parsing, state transitions, validation, and regressions. ## Test Before Opening a Pull Request -Run the license-free repository checks: +Run the checks that do not require a Unity license: ```bash python3 .github/scripts/validate_repository.py npx --yes markdownlint-cli2@0.23.0 "**/*.md" "#Library" "#Temp" -npm pack --dry-run +npm pack --ignore-scripts --dry-run ``` In Unity: @@ -130,17 +133,16 @@ the `PackageManagerCompatibility` category. After it passes, run the complete `MartinCalander.GitSubmoduleManager.Editor.Tests` assembly because behavior and state-transition coverage intentionally lives outside the compatibility subset. -Maintainers can also dispatch **Sanity Checks** with a reviewed full commit SHA -and an exact `unity_version` of `6000.3.22f1` or a `6000.5.*f1` final release. -Manual dispatch stages the same package revision and runs the full EditMode -assembly in that Editor; normal protected push runs remain pinned to the minimum -`6000.3.22f1` eligibility baseline. Unity 6000.4 is not a validated target. - CI runs license-free structure, Markdown, archive, and portability checks on -every pull request. Unity credentials are never exposed to pull-request code. -After reviewing a contribution, a maintainer can manually dispatch the -**Sanity Checks** workflow with the reviewed commit as its `ref` to run the -protected Unity compile and EditMode-test gate. +every pull request without exposing Unity credentials to pull-request code. +After reviewing a contribution, a maintainer can dispatch **Sanity Checks** +from protected `main` with the full commit SHA and an exact `unity_version` of +`6000.3.22f1` or `6000.5.0f1`. Those CI choices use reviewed, digest-pinned +GameCI images; additional `6000.5.*f1` final patches can be checked locally +before their image digest is added. The job stages that exact package revision +and runs the full EditMode assembly. Protected push runs remain pinned to the +minimum `6000.3.22f1` eligibility baseline. Unity 6000.4 is not a validated +target. Branch protection requires the workflow's stable **Required sanity gate**, which aggregates package validation and the complete Linux, macOS, and Windows diff --git a/.github/GOVERNANCE.md b/.github/GOVERNANCE.md index e41fe70..4233020 100644 --- a/.github/GOVERNANCE.md +++ b/.github/GOVERNANCE.md @@ -11,8 +11,8 @@ Decisions are evaluated in this order: 1. protect users and their repositories from data loss or unsafe mutations; 2. preserve compatibility across supported Unity versions, Windows, macOS, and Linux; -3. keep the package editor-only and focused on Git submodules directly below - `Packages/`; +3. keep the package editor-only and focused on Git-backed UPM packages, with + editable submodules limited to direct children of `Packages/`; 4. maintain clear, testable, and reviewable code; 5. improve performance where it does not compromise correctness or clarity. @@ -56,15 +56,19 @@ before opening the normal public discussion. - Changes normally enter `main` through a pull request. - Required CI checks must pass before merge. -- At least one maintainer approval is expected for code changes. -- Authors do not approve their own changes when another active maintainer is +- Code changes should receive an independent maintainer review when one is available. +- When the project has only one active maintainer, the project lead may merge + after required CI passes and must record the verification performed in the + pull request. +- Authors do not approve their own changes. - Force pushes to protected branches and rewriting published release tags are not part of the normal workflow. -Repository administrators may bypass the normal process only to respond to a -security incident, restore a broken release or automation path, or recover the -repository. The reason should be documented afterward when disclosure is safe. +Repository administrators may otherwise bypass the normal process only to +respond to a security incident, restore a broken release or automation path, or +recover the repository. The reason should be documented afterward when +disclosure is safe. ## Releases diff --git a/.github/MAINTAINERS.md b/.github/MAINTAINERS.md index 7669903..5c8e510 100644 --- a/.github/MAINTAINERS.md +++ b/.github/MAINTAINERS.md @@ -32,20 +32,15 @@ practical, the reason should be stated clearly. ## Becoming a Maintainer -Maintainers are appointed by the project lead based on sustained, constructive -participation. Useful signals include sound technical judgment, respectful -reviews, reliable follow-through, cross-platform awareness, and careful -handling of failure and security cases. - -There is no required number of commits and no automatic promotion. Repository -permissions are granted gradually and should remain no broader than necessary. +Martin Calander appoints maintainers after sustained contributions demonstrate +sound technical judgment, reliable follow-through, cross-platform awareness, +and careful handling of Git, Unity, and security failures. There is no commit +quota. Repository permissions are granted gradually and remain no broader than +necessary. ## Inactivity and Removal -A maintainer may step down at any time. The project lead may move an inactive -maintainer to emeritus status, reduce permissions that are no longer needed, or -remove a maintainer for security, conduct, or trust reasons. - -Before a planned departure, maintainers should transfer open security work, -release responsibilities, and access that would otherwise leave the project -without an accountable owner. +A maintainer may step down at any time. The project lead may reduce or remove +access that is no longer needed, including after prolonged inactivity or for +security, conduct, or trust reasons. Before leaving, a maintainer should hand +off open security reports, release work, and any access that only they control. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6ec4373..0c72faf 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,14 +8,18 @@ Describe the user-facing change and why it is needed. - [ ] Unity compiles with no new warnings or errors. - [ ] Focused EditMode tests pass. - [ ] The changed workflow was tested manually. -- [ ] Relevant Windows, macOS, and Linux impact was considered. +- [ ] I tested every affected platform, or listed below any platform I could not + test. List exact commands, Unity versions, operating systems, and any skipped checks. ## Safety and Compatibility -- [ ] Mutations remain restricted to direct packages under `Packages/`. -- [ ] No shell evaluation, credential storage, system installer, or implicit startup mutation was introduced. +- [ ] Submodule filesystem changes stay within validated direct + `Packages/` children. +- [ ] User input is not passed through a shell. +- [ ] No credential storage, system installer execution, or implicit startup + mutation was introduced. - [ ] Documentation and `CHANGELOG.md` were updated when behavior changed. - [ ] New Unity assets were imported and include generated `.meta` files. diff --git a/.github/RELEASING.md b/.github/RELEASING.md index 7da0226..f2f896a 100644 --- a/.github/RELEASING.md +++ b/.github/RELEASING.md @@ -1,8 +1,8 @@ # Releasing Git Submodule Manager -This guide is for maintainers publishing a GitHub and UPM release. Releases -follow [Semantic Versioning](https://semver.org/) and are produced by the -[Publish Release workflow](workflows/release.yml). +This guide is for maintainers publishing a GitHub release for the UPM package. +Releases follow [Semantic Versioning](https://semver.org/) and are produced by +the [Publish Release workflow](workflows/release.yml). ## Release Authority @@ -25,9 +25,14 @@ Before enabling credentialed CI, configure two protected GitHub environments: Do not store Unity credentials as general repository secrets. Set the repository variable `UNITY_CI_ENABLED=true` only after `unity-ci` is protected. Protect -`main` from direct/force pushes, require pull-request review and sanity checks, -and add a tag protection rule for `v*` before publishing. These settings are -repository controls and cannot be enforced by workflow YAML alone. +`main` from direct and force pushes, require pull requests and sanity checks, +and require independent review when another maintainer is available. Add a tag +protection rule for `v*` before publishing. These settings are repository +controls and cannot be enforced by workflow YAML alone. + +Keep the GameCI action and each release-matrix Editor image pinned to reviewed +commit and image digests. Adding another Unity patch to CI requires recording +its exact image digest in both workflows before it can become a release gate. ## Version Policy @@ -46,7 +51,8 @@ The version in [`package.json`](../package.json) and the tag without its leading 1. Work from a clean branch based on the latest `main`. 2. Update `package.json` to the intended version. 3. Move relevant entries from **Unreleased** into a dated version section in - [`CHANGELOG.md`](../CHANGELOG.md). + [`CHANGELOG.md`](../CHANGELOG.md), remove any unpublished-version note, and + update its comparison links for the intended tag. 4. Confirm installation, compatibility, and troubleshooting documentation are still accurate. 5. Run the repository checks: @@ -54,15 +60,17 @@ The version in [`package.json`](../package.json) and the tag without its leading ```bash python3 .github/scripts/validate_repository.py npx --yes markdownlint-cli2@0.23.0 "**/*.md" "#Library" "#Temp" - npm pack --dry-run + npm pack --ignore-scripts --dry-run ``` 6. In a clean Unity project, install the package from the exact release commit, - run `MartinCalander.GitSubmoduleManager.Editor.Tests` in EditMode, and exercise add, update, - and remove behavior. + run `MartinCalander.GitSubmoduleManager.Editor.Tests` in EditMode, then test + submodule and read-only installation, both eligible conversions, and + submodule removal. 7. Record any platform or Unity version that could not be tested in the release pull request. -8. Merge the release pull request only after required checks and review pass. +8. Merge the release pull request only after required checks pass and the + applicable review policy is satisfied. Fork pull requests never receive Unity credentials. A maintainer must inspect the contribution and manually dispatch **Sanity Checks** with its reviewed, @@ -101,9 +109,9 @@ Pushing the tag starts the Publish Release workflow. The workflow: Pre-release SemVer tags are published as GitHub pre-releases. The workflow can also be started manually for an existing tag. Manual dispatch -does not create or move a tag. Run the workflow itself from the same exact -protected tag that is supplied as its input so the protected `release` -environment evaluates the reviewed release ref: +does not create or move a tag. Start the workflow from the same protected tag +provided as its input so the `release` environment evaluates the reviewed +release ref: ```bash gh workflow run release.yml --ref v2.0.0 -f tag=v2.0.0 @@ -134,8 +142,8 @@ Do not rewrite or reuse a published version or tag. If a release is defective: 1. document the impact; 2. prepare a new patch version; 3. repeat the normal validation and publication process; -4. mark the affected GitHub release as deprecated only when that context helps - users. +4. update the affected release notes to point users to the fixed version when + that context is useful. For a security release, coordinate timing and disclosure through [SECURITY.md](SECURITY.md). diff --git a/.github/REPOSITORY_SETUP.md b/.github/REPOSITORY_SETUP.md index 6b618d5..85fabfd 100644 --- a/.github/REPOSITORY_SETUP.md +++ b/.github/REPOSITORY_SETUP.md @@ -6,11 +6,10 @@ settings are not stored in Git and must be configured in GitHub. ## General - [ ] Confirm the repository owner and default branch are correct. -- [ ] Add the description: `A safe, cross-platform Unity Editor workflow for managing Git submodules as UPM packages.` +- [ ] Add the description: `Manage Git-hosted UPM packages in Unity as editable submodules or read-only dependencies.` - [ ] Add topics: `unity`, `unity-editor`, `upm`, `git`, `submodule`, `package-manager`, `developer-tools`. -- [ ] Leave the social preview unset unless a human-designed, project-specific - image is available; do not use a generic generated banner. +- [ ] Use a project-specific social preview, or leave it unset. - [ ] Confirm GitHub recognizes the MIT license. - [ ] Enable Issues so the bundled forms become available. @@ -19,8 +18,8 @@ settings are not stored in Git and must be configured in GitHub. Protect `main` with: - [ ] pull requests required before merging; -- [ ] at least one approving review; -- [ ] review from the package Code Owner required; +- [ ] one approving review and Code Owner review when a second active maintainer + is available; - [ ] stale approvals dismissed when new commits are pushed; - [ ] conversation resolution required; - [ ] `Required sanity gate` required from the **Sanity Checks** workflow, with @@ -54,9 +53,10 @@ Protect release tags with a `v*` tag ruleset: - [ ] Enable immutable releases before publishing the first release. - [ ] Enable GitHub private vulnerability reporting and keep the security email in `.github/SECURITY.md` monitored as a fallback. -- [ ] Create the referenced labels (`needs-triage`, `support`, `dependencies`, - `automation`, `maintenance`, `feature`, and `fix`) before enabling Issues or - release automation, then confirm both issue forms can be submitted. +- [ ] Create the referenced labels (`needs-triage`, `support`, `bug`, + `enhancement`, `documentation`, `dependencies`, `automation`, `maintenance`, + `feature`, and `fix`) before enabling Issues or release automation, then + confirm all three issue forms can be submitted. ## Release diff --git a/.github/SECURITY.md b/.github/SECURITY.md index f4ca317..b279607 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,14 +2,14 @@ ## Supported Versions -Security fixes are applied to the latest released major version and the current -`main` branch. +Security fixes are applied to the latest 2.x release and the current `main` +branch. -| Version | Supported | +| Version or ref | Supported | | --- | --- | | 2.x | Yes | -| 1.x | No | -| 0.x | No | +| `main` | Yes | +| 1.x and earlier | No | ## Report a Vulnerability @@ -23,12 +23,12 @@ Email [martin.calander@gmail.com](mailto:martin.calander@gmail.com) with: - relevant operating system, Unity, Git, and GitHub CLI versions; - suggested remediation, if available. -Do not include real credentials or third-party private repository data. Encrypt -sensitive attachments before sending and request a secure exchange method when -needed. +Do not include real credentials or third-party private repository data. Do not +email sensitive attachments; ask for a secure exchange method first. -You can expect acknowledgment within 72 hours and an initial assessment within -seven days. Disclosure timing will be coordinated around a fix and release. +I aim to acknowledge reports within 72 hours and provide an initial assessment +within seven days. Disclosure timing will be coordinated around a fix and +release. ## Security Model @@ -40,7 +40,8 @@ The package launches `git` and optional `gh` processes with - repository URLs, branch names, package names, and managed paths are validated; - network repositories are limited to HTTPS and SSH; plaintext `http://`, `git://`, embedded credentials, and executable remote helpers are rejected; -- mutations are restricted to direct `Packages/com.author.package` paths; +- submodule filesystem changes are restricted to validated direct + `Packages/` children; - stdout and stderr are redirected, drained concurrently, bounded, and treated as unusable for structural parsing when incomplete; - commands have bounded timeouts; @@ -58,17 +59,20 @@ credential manager, SSH agent, and GitHub CLI. ### Network access - Git performs clone, fetch, remote branch, and submodule operations. -- GitHub CLI performs authenticated repository discovery and root - `package.json` checks. +- GitHub CLI performs authenticated repository discovery and retrieves root + package metadata. Catalogue entries require a valid root `package.json` and + matching `package.json.meta` from the same commit. - The package does not contain its own HTTP client or telemetry. ### Filesystem access -The package reads project `.gitmodules` and package metadata. Mutating package -operations are constrained to validated direct children of `Packages/`. +The package reads project `.gitmodules` and package metadata. Submodule +filesystem changes are constrained to validated direct children of `Packages/`. Persisted submodule URLs, local Git configuration, worktree origins, and -postconditions are revalidated around mutations. Root manifests must be bounded -regular UTF-8 files rather than symbolic links or reparse points. +postconditions are revalidated around mutations. Catalogue entries and +read-only installs require bounded, strict-UTF-8 root `package.json` and +`package.json.meta` blobs from the same commit. Local package metadata must be +regular files rather than symbolic links or reparse points. ## User Responsibilities diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md index 34709c6..0ebc84f 100644 --- a/.github/SUPPORT.md +++ b/.github/SUPPORT.md @@ -3,27 +3,36 @@ ## Before Asking for Help Check the [troubleshooting guide](../Documentation~/troubleshooting.md), then -confirm these commands work in the same user account that launches Unity: +confirm that Git works in the same user account that launches Unity: ```bash git --version +``` + +If the problem involves **Sources > GitHub**, also check the optional GitHub +CLI and its authentication: + +```bash gh --version gh auth status -h github.com ``` -Only Git is required. GitHub CLI (`gh`) is optional and powers repository -discovery. +GitHub CLI (`gh`) is not required for direct URL installation or other Git-only +package operations. ## Where to Ask -- Use the [support request form](https://github.com/martincalander/GitSubmoduleManager/issues/new?template=support_request.yml) - for setup questions and usage help. -- Use the [bug report form](https://github.com/martincalander/GitSubmoduleManager/issues/new?template=bug_report.yml) - for reproducible defects. -- Use the [feature request form](https://github.com/martincalander/GitSubmoduleManager/issues/new?template=feature_request.yml) - for scoped enhancements. -- Follow [SECURITY.md](SECURITY.md) for vulnerabilities. Do not publish - security-sensitive details in a public issue. +Open the repository's **Issues** tab and choose the form that fits: + +- **Support request** for setup questions and usage help; +- **Bug report** for reproducible defects; +- **Feature request** for scoped enhancements. + +If the **Issues** tab is unavailable, the repository owner has not finished the +[publication setup](REPOSITORY_SETUP.md). + +For vulnerabilities, follow [SECURITY.md](SECURITY.md). Do not publish +security-sensitive details in a public issue. ## Useful Diagnostic Information diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b79769..2f844e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,13 @@ on: required: false type: string unity_version: - description: Unity Editor version for the manual compatibility run + description: Pinned Unity Editor image for the manual compatibility run required: false default: 6000.3.22f1 - type: string + type: choice + options: + - 6000.3.22f1 + - 6000.5.0f1 permissions: contents: read @@ -53,9 +56,18 @@ jobs: - name: Build package archive run: | + set -euo pipefail mkdir -p artifacts npm pack --ignore-scripts --pack-destination artifacts - (cd artifacts && sha256sum -- ./*.tgz > SHA256SUMS) + mapfile -t archives < <( + find artifacts -maxdepth 1 -type f -name '*.tgz' -print + ) + if (( ${#archives[@]} != 1 )); then + echo "Expected exactly one UPM package archive." >&2 + exit 1 + fi + archive_name="$(basename "${archives[0]}")" + (cd artifacts && sha256sum -- "./$archive_name" > SHA256SUMS) (cd artifacts && sha256sum --check SHA256SUMS) - name: Upload package preview @@ -155,19 +167,99 @@ jobs: exit 1 fi - - name: Validate requested Unity version + - name: Resolve pinned Unity image + id: unity-image env: REQUESTED_UNITY_VERSION: ${{ inputs.unity_version || '6000.3.22f1' }} run: | - if [[ ! "$REQUESTED_UNITY_VERSION" =~ ^(6000\.3\.22f1|6000\.5\.[0-9]+f1)$ ]]; then - echo "Unity version must be 6000.3.22f1 or a 6000.5.*f1 final release." >&2 + case "$REQUESTED_UNITY_VERSION" in + 6000.3.22f1) + image="unityci/editor:ubuntu-6000.3.22f1-linux-il2cpp-3@sha256:bd9f0c77473bc842423236ec1498f180380f734dde521397e0fac2319865e87a" + ;; + 6000.5.0f1) + image="unityci/editor:ubuntu-6000.5.0f1-linux-il2cpp-3@sha256:318fd4f7081fd1fed6f3a850aa22fb39fc185d6d798915ccc803a37a5596ef82" + ;; + *) + echo "Unity CI has no reviewed image for $REQUESTED_UNITY_VERSION." >&2 + exit 1 + ;; + esac + echo "image=$image" >> "$GITHUB_OUTPUT" + + - name: Build exact UPM test archive + run: | + set -euo pipefail + mkdir -p .ci/dist .ci/extracted + npm pack --ignore-scripts --pack-destination .ci/dist + mapfile -t archives < <( + find .ci/dist -maxdepth 1 -type f -name '*.tgz' -print + ) + if (( ${#archives[@]} != 1 )); then + echo "Expected exactly one UPM test archive." >&2 exit 1 fi + tar -xzf "${archives[0]}" -C .ci/extracted + test -f .ci/extracted/package/package.json - - name: Stage package below the repository root + - name: Create deterministic Unity archive test project + id: test-project + env: + PACKAGE_NAME: com.martincalander.gitsubmodulemanager + UNITY_VERSION: ${{ inputs.unity_version || '6000.3.22f1' }} run: | - mkdir -p .ci/package - git archive --format=tar HEAD | tar -xf - -C .ci/package + set -euo pipefail + + mapfile -t archives < <( + find .ci/dist -maxdepth 1 -type f -name '*.tgz' -print + ) + if (( ${#archives[@]} != 1 )); then + echo "Expected exactly one UPM test archive." >&2 + exit 1 + fi + + case "$UNITY_VERSION" in + 6000.3.22f1) test_framework_version=1.6.0 ;; + 6000.5.0f1) test_framework_version=1.7.0 ;; + *) echo "Unsupported Unity version: $UNITY_VERSION" >&2; exit 1 ;; + esac + + archive="${archives[0]}" + archive_name="$(basename "$archive")" + package_version="$(jq -er '.version' .ci/extracted/package/package.json)" + expected_archive="$PACKAGE_NAME-$package_version.tgz" + if [[ "$archive_name" != "$expected_archive" ]]; then + echo "Unexpected archive name: $archive_name" >&2 + exit 1 + fi + + project=.ci/unity-test-project + package_reference="file:../../dist/$archive_name" + mkdir -p \ + "$project/Assets" \ + "$project/Packages" \ + "$project/ProjectSettings" + + jq -n \ + --arg package_name "$PACKAGE_NAME" \ + --arg package_reference "$package_reference" \ + --arg test_framework "$test_framework_version" ' + { + dependencies: { + "com.unity.test-framework": $test_framework + }, + testables: [$package_name], + enableLockFile: true + } + | .dependencies[$package_name] = $package_reference + ' > "$project/Packages/manifest.json" + printf 'm_EditorVersion: %s\n' "$UNITY_VERSION" \ + > "$project/ProjectSettings/ProjectVersion.txt" + + resolved_archive="$( + realpath "$project/Packages/${package_reference#file:}" + )" + test "$resolved_archive" = "$(realpath "$archive")" + echo "package_reference=$package_reference" >> "$GITHUB_OUTPUT" - name: Validate Unity license configuration env: @@ -185,7 +277,7 @@ jobs: exit 1 fi - - name: Compile package and run EditMode tests + - name: Compile exact archive and run EditMode tests id: unity-tests uses: game-ci/unity-test-runner@0ff419b913a3630032cbe0de48a0099b5a9f0ed9 # v4.3.1 env: @@ -194,12 +286,113 @@ jobs: UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} with: - packageMode: true - projectPath: .ci/package + packageMode: false + projectPath: .ci/unity-test-project unityVersion: ${{ inputs.unity_version || '6000.3.22f1' }} + customImage: ${{ steps.unity-image.outputs.image }} testMode: EditMode artifactsPath: unity-test-results + - name: Verify exact archive test evidence + env: + PACKAGE_NAME: com.martincalander.gitsubmodulemanager + PACKAGE_REFERENCE: ${{ steps.test-project.outputs.package_reference }} + run: | + set -euo pipefail + + lockfile=.ci/unity-test-project/Packages/packages-lock.json + test -f "$lockfile" + jq -e \ + --arg package_name "$PACKAGE_NAME" \ + --arg package_reference "$PACKAGE_REFERENCE" ' + .dependencies[$package_name] as $package + | $package.depth == 0 + and $package.source == "local-tarball" + and $package.version == $package_reference + and (.dependencies | has("com.unity.testtools.codecoverage") | not) + ' "$lockfile" > /dev/null + + python3 - <<'PY' + import sys + import xml.etree.ElementTree as ET + + root = ET.parse("unity-test-results/editmode-results.xml").getroot() + total = int(root.attrib.get("total", "0")) + passed = int(root.attrib.get("passed", "0")) + skipped_count = int(root.attrib.get("skipped", "0")) + if ( + root.tag != "test-run" + or total <= 0 + or int(root.attrib.get("failed", "0")) != 0 + or int(root.attrib.get("inconclusive", "0")) != 0 + or passed + skipped_count != total + or root.attrib.get("result") + not in {"Passed", "Skipped", "Skipped:Ignored"} + ): + raise SystemExit(f"Invalid NUnit release evidence: {root.attrib}") + + allowed_skips = { + ( + "MartinCalander.GitSubmoduleManager.Editor.Tests." + "PackageManagerGitHubPackageProjectionTests." + "Reconcile_ExcludesAnAlreadyInstalledPackageName" + ), + ( + "MartinCalander.GitSubmoduleManager.Editor.Tests." + "PackageManagerGitSubmoduleInstallMenuTests." + "InstallForRoot_RepeatedTrackedRootDoesNotDuplicateNativeItem" + ), + ( + "MartinCalander.GitSubmoduleManager.Editor.Tests." + "PackageManagerSubmodulePresentationTests." + "ActivePageReflectionContract_OpenWindowProvidesDiagnostic" + ), + } + cases = list(root.iter("test-case")) + skipped = { + case.attrib.get("fullname", case.attrib.get("name", "")) + for case in cases + if case.attrib.get("result") == "Skipped" + } + unexpected_results = [ + case.attrib + for case in cases + if case.attrib.get("result") not in {"Passed", "Skipped"} + ] + if ( + len(cases) != total + or len(skipped) != skipped_count + or not skipped.issubset(allowed_skips) + or unexpected_results + ): + raise SystemExit( + "Unexpected NUnit case results or skips: " + f"skipped={sorted(skipped)} results={unexpected_results}" + ) + + assembly_name = ( + "MartinCalander.GitSubmoduleManager.Editor.Tests.dll" + ) + assemblies = [ + suite for suite in root.iter("test-suite") + if suite.attrib.get("type") == "Assembly" + and suite.attrib.get("name") == assembly_name + and int(suite.attrib.get("total", "0")) > 0 + ] + if len(assemblies) != 1: + raise SystemExit( + f"Expected one nonempty {assembly_name} suite" + ) + if skipped: + print("Allowed environment-dependent skips:", file=sys.stderr) + for test_name in sorted(skipped): + print(f"- {test_name}", file=sys.stderr) + print( + "Verified exact archive test evidence: " + f"total={total}, passed={passed}, skipped={skipped_count}" + ) + PY + - name: Upload Unity test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81fe53f..e647748 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,9 +115,18 @@ jobs: - name: Build immutable release archive run: | + set -euo pipefail mkdir -p dist npm pack --ignore-scripts --pack-destination dist - (cd dist && sha256sum -- ./*.tgz > SHA256SUMS) + mapfile -t archives < <( + find dist -maxdepth 1 -type f -name '*.tgz' -print + ) + if (( ${#archives[@]} != 1 )); then + echo "Expected exactly one release archive." >&2 + exit 1 + fi + archive_name="$(basename "${archives[0]}")" + (cd dist && sha256sum -- "./$archive_name" > SHA256SUMS) (cd dist && sha256sum --check SHA256SUMS) - name: Upload immutable release archive @@ -137,9 +146,17 @@ jobs: strategy: fail-fast: false matrix: - unity-version: - - 6000.3.22f1 - - 6000.5.0f1 + include: + - unity-version: 6000.3.22f1 + unity-revision: 1c726e1fb402 + test-framework-version: 1.6.0 + custom-image: >- + unityci/editor:ubuntu-6000.3.22f1-linux-il2cpp-3@sha256:bd9f0c77473bc842423236ec1498f180380f734dde521397e0fac2319865e87a + - unity-version: 6000.5.0f1 + unity-revision: 88b47c5e7076 + test-framework-version: 1.7.0 + custom-image: >- + unityci/editor:ubuntu-6000.5.0f1-linux-il2cpp-3@sha256:318fd4f7081fd1fed6f3a850aa22fb39fc185d6d798915ccc803a37a5596ef82 steps: - name: Download validated release archive @@ -150,10 +167,80 @@ jobs: - name: Extract the exact archive that will be published run: | + set -euo pipefail + mapfile -t archives < <( + find dist -maxdepth 1 -type f -name '*.tgz' -print + ) + if (( ${#archives[@]} != 1 )); then + echo "Expected exactly one release archive." >&2 + exit 1 + fi + (cd dist && sha256sum --check SHA256SUMS) mkdir -p .ci - tar -xzf dist/*.tgz -C .ci + tar -xzf "${archives[0]}" -C .ci test -f .ci/package/package.json - (cd dist && sha256sum --check SHA256SUMS) + + - name: Create deterministic Unity archive test project + id: test-project + env: + PACKAGE_NAME: com.martincalander.gitsubmodulemanager + TEST_FRAMEWORK_VERSION: ${{ matrix.test-framework-version }} + UNITY_REVISION: ${{ matrix.unity-revision }} + UNITY_VERSION: ${{ matrix.unity-version }} + run: | + set -euo pipefail + + mapfile -t archives < <( + find dist -maxdepth 1 -type f -name '*.tgz' -print + ) + if (( ${#archives[@]} != 1 )); then + echo "Expected exactly one release archive." >&2 + exit 1 + fi + + archive="${archives[0]}" + archive_name="$(basename "$archive")" + package_version="$(jq -er '.version' .ci/package/package.json)" + expected_archive="$PACKAGE_NAME-$package_version.tgz" + if [[ "$archive_name" != "$expected_archive" ]]; then + echo "Unexpected archive name: $archive_name" >&2 + exit 1 + fi + + project=.ci/release-test-project + package_reference="file:../../../dist/$archive_name" + mkdir -p \ + "$project/Assets" \ + "$project/Packages" \ + "$project/ProjectSettings" + + jq -n \ + --arg package_name "$PACKAGE_NAME" \ + --arg package_reference "$package_reference" \ + --arg test_framework "$TEST_FRAMEWORK_VERSION" ' + { + dependencies: { + "com.unity.test-framework": $test_framework + }, + testables: [$package_name], + enableLockFile: true + } + | .dependencies[$package_name] = $package_reference + ' > "$project/Packages/manifest.json" + + printf \ + 'm_EditorVersion: %s\nm_EditorVersionWithRevision: %s (%s)\n' \ + "$UNITY_VERSION" \ + "$UNITY_VERSION" \ + "$UNITY_REVISION" \ + > "$project/ProjectSettings/ProjectVersion.txt" + + resolved_archive="$( + realpath "$project/Packages/${package_reference#file:}" + )" + test "$resolved_archive" = "$(realpath "$archive")" + echo "archive_name=$archive_name" >> "$GITHUB_OUTPUT" + echo "package_reference=$package_reference" >> "$GITHUB_OUTPUT" - name: Validate Unity license configuration env: @@ -163,15 +250,15 @@ jobs: UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} run: | if [[ -z "$UNITY_EMAIL" || -z "$UNITY_PASSWORD" ]]; then - echo "UNITY_EMAIL and UNITY_PASSWORD repository secrets are required." >&2 + echo "UNITY_EMAIL and UNITY_PASSWORD environment secrets are required." >&2 exit 1 fi if [[ -z "$UNITY_LICENSE" && -z "$UNITY_SERIAL" ]]; then - echo "Set UNITY_LICENSE (Personal) or UNITY_SERIAL (Pro) as a repository secret." >&2 + echo "Set UNITY_LICENSE (Personal) or UNITY_SERIAL (Pro) as an environment secret." >&2 exit 1 fi - - name: Compile package and run EditMode tests + - name: Compile exact archive and run EditMode tests uses: game-ci/unity-test-runner@0ff419b913a3630032cbe0de48a0099b5a9f0ed9 # v4.3.1 env: UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} @@ -179,12 +266,161 @@ jobs: UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} with: - packageMode: true - projectPath: .ci/package + packageMode: false + projectPath: .ci/release-test-project unityVersion: ${{ matrix.unity-version }} + customImage: ${{ matrix.custom-image }} testMode: EditMode artifactsPath: unity-release-test-results-${{ matrix.unity-version }} + - name: Verify exact archive test evidence + env: + PACKAGE_NAME: com.martincalander.gitsubmodulemanager + PACKAGE_REFERENCE: ${{ steps.test-project.outputs.package_reference }} + RESULT_PATH: unity-release-test-results-${{ matrix.unity-version }}/editmode-results.xml + run: | + set -euo pipefail + + manifest=.ci/release-test-project/Packages/manifest.json + lockfile=.ci/release-test-project/Packages/packages-lock.json + test -f "$lockfile" + jq -e \ + --arg package_name "$PACKAGE_NAME" \ + --arg package_reference "$PACKAGE_REFERENCE" ' + .dependencies[$package_name] as $package + | $package.depth == 0 + and $package.source == "local-tarball" + and $package.version == $package_reference + and (.dependencies | has("com.unity.testtools.codecoverage") | not) + ' "$lockfile" > /dev/null + jq -e ' + .dependencies | has("com.unity.testtools.codecoverage") | not + ' "$manifest" > /dev/null + + python3 - <<'PY' + import os + import sys + import xml.etree.ElementTree as ET + + result_path = os.environ["RESULT_PATH"] + root = ET.parse(result_path).getroot() + if root.tag != "test-run": + raise SystemExit(f"Unexpected NUnit root: {root.tag}") + + total = int(root.attrib.get("total", "0")) + passed = int(root.attrib.get("passed", "0")) + failed = int(root.attrib.get("failed", "0")) + inconclusive = int(root.attrib.get("inconclusive", "0")) + skipped_count = int(root.attrib.get("skipped", "0")) + if ( + total <= 0 + or failed != 0 + or inconclusive != 0 + or passed + skipped_count != total + ): + raise SystemExit( + "Invalid NUnit totals: " + f"total={total} passed={passed} failed={failed} " + f"inconclusive={inconclusive} skipped={skipped_count}" + ) + if root.attrib.get("result") not in { + "Passed", + "Skipped", + "Skipped:Ignored", + }: + raise SystemExit( + f"NUnit run did not pass: {root.attrib.get('result')}" + ) + + allowed_skips = { + ( + "MartinCalander.GitSubmoduleManager.Editor.Tests." + "PackageManagerGitHubPackageProjectionTests." + "Reconcile_ExcludesAnAlreadyInstalledPackageName" + ), + ( + "MartinCalander.GitSubmoduleManager.Editor.Tests." + "PackageManagerGitSubmoduleInstallMenuTests." + "InstallForRoot_RepeatedTrackedRootDoesNotDuplicateNativeItem" + ), + ( + "MartinCalander.GitSubmoduleManager.Editor.Tests." + "PackageManagerSubmodulePresentationTests." + "ActivePageReflectionContract_OpenWindowProvidesDiagnostic" + ), + } + all_cases = list(root.iter("test-case")) + skipped = { + case.attrib.get("fullname", case.attrib.get("name", "")) + for case in all_cases + if case.attrib.get("result") == "Skipped" + } + unexpected_results = [ + case.attrib + for case in all_cases + if case.attrib.get("result") not in {"Passed", "Skipped"} + ] + if ( + len(all_cases) != total + or len(skipped) != skipped_count + or not skipped.issubset(allowed_skips) + or unexpected_results + ): + raise SystemExit( + "Unexpected NUnit case results or skips: " + f"skipped={sorted(skipped)} results={unexpected_results}" + ) + + assembly_name = ( + "MartinCalander.GitSubmoduleManager.Editor.Tests.dll" + ) + assemblies = [ + suite for suite in root.iter("test-suite") + if suite.attrib.get("type") == "Assembly" + and suite.attrib.get("name") == assembly_name + ] + if len(assemblies) != 1: + raise SystemExit( + f"Expected one {assembly_name} suite, found {len(assemblies)}" + ) + if int(assemblies[0].attrib.get("total", "0")) <= 0: + raise SystemExit(f"{assembly_name} did not run any tests") + + compatibility_fixtures = ( + "PackageManagerCompatibilityContractTests", + "PackageManagerUnityVersionCollectionContractTests", + ) + for fixture_name in compatibility_fixtures: + fixtures = [ + suite for suite in root.iter("test-suite") + if suite.attrib.get("type") == "TestFixture" + and suite.attrib.get("name") == fixture_name + ] + if len(fixtures) != 1: + raise SystemExit( + f"Expected one {fixture_name} fixture, found {len(fixtures)}" + ) + cases = list(fixtures[0].iter("test-case")) + if not cases or any( + case.attrib.get("result") != "Passed" for case in cases + ): + raise SystemExit( + f"Every {fixture_name} case must run and pass" + ) + + if skipped: + print("Allowed environment-dependent skips:", file=sys.stderr) + for test_name in sorted(skipped): + print(f"- {test_name}", file=sys.stderr) + + print( + "Verified exact archive test evidence: " + f"total={total}, passed={passed}, skipped={skipped_count}" + ) + PY + + (cd dist && sha256sum --check SHA256SUMS) + - name: Upload Unity test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.npmignore b/.npmignore index bcdbba9..0fc70a5 100644 --- a/.npmignore +++ b/.npmignore @@ -1,4 +1,7 @@ .github/ +.ci/ .markdownlint-cli2.jsonc +artifacts/ CITATION.cff CITATION.cff.meta +dist/ diff --git a/AUTHORS.md b/AUTHORS.md index 455ee75..4d29065 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -2,7 +2,7 @@ ## Creator and Maintainer -- **Martin Calander** — original concept, design, implementation, and maintenance +- **Martin Calander** — creator and maintainer - [GitHub](https://github.com/martincalander) - [Website](https://martincalander.com) @@ -11,5 +11,5 @@ Community contributions are credited through the repository's [contributors page](https://github.com/martincalander/GitSubmoduleManager/graphs/contributors). -By contributing, you agree that your work is distributed under the project's -[MIT License](LICENSE.md). +Contributors retain authorship of their work. The combined project is +distributed under the [MIT License](LICENSE.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 1544c9e..4feb5ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,80 +7,45 @@ and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Security - -- Structural command output that requires UTF-8 is now decoded from raw bytes - with BOM auto-detection disabled and the strict decoder flushed at EOF. This - prevents UTF-16 BOM content from being accepted as a package manifest or - Unity meta marker while retaining bounded, concurrent stream draining. -- Registry fallback for custom dependencies is now bound to the exact complete - GitHub catalogue revision that proved package absence. If discovery revision - or coverage changes while a registry search is pending, the stale result is - discarded and the requirement is resolved again against current evidence. -- Submodule add completion now binds the package gitlink and immutable staged - `.gitmodules` blob in one index snapshot, verifies the initialized child - `origin`, exact `HEAD`, and regular bounded worktree file, and repeats the - terminal proof around the origin read. Stage-only redirects, late origin or - commit swaps, late dirty or interrupted child state, linked `.gitmodules` - files, and unstable evidence fail closed. -- Dependency-step completion now requires each fresh worktree `.gitmodules` - proof to be one regular, non-linked, strict-UTF-8 file no larger than 128 KiB - whose raw Git blob identity equals the staged registration, including at the - terminal acceptance boundary. -- Failed-add rollback is now bound to the exact add-produced gitlink and staged - `.gitmodules` identity and proceeds only when target-section removal exactly - reproduces the pre-add baseline. Recovery postconditions are non-cancellable, - and unsafe outcomes report the exact preserved worktree and metadata paths. -- Submodule removal now closes on the exact desired index, regular worktree - identity, quiet diff, and package absence after late hooks. Concurrent regular - or linked replacements are preserved; CRLF worktrees are accepted only when - exact CRLF-to-LF normalization hashes to the staged blob. -- Reload journals are now read through bounded strict-UTF-8 regular-file - snapshots with nonblocking, no-follow POSIX opens. Replacement and deletion - use exact identity checks, and quarantined journal bytes remain recoverable so - a late writer can never be unlinked as cleanup-owned data. -- Manifest compare-and-swap cleanup now atomically quarantines and retains each - exact randomized sibling instead of deleting it after a mutable byte read. - Late or ambiguous writer bytes therefore remain recoverable under their - unique sibling path. - -## [2.0.0] - 2026-08-27 +## [2.0.0] - 2026-08-29 ### Security -- GitHub catalogue discovery now requires a valid regular root - `package.json.meta`, fetched with `package.json` from the same commit, before a - repository is classified as a Unity package. The marker is bounded and must - contain `fileFormatVersion: 2` plus one nonzero root GUID. -- Explicit direct-URL installs still accept a valid root UPM manifest when the - Unity meta marker is missing or invalid, but now show a mandatory warning; - verified marker identity is carried into post-install validation. Git tree - modes and exact blob IDs are checked so symbolic-link entries cannot satisfy - manifest or verified-meta validation. -- Read-only catalogue and dependency installs now pin the exact commit whose - root manifest and meta marker were inspected, retain that commit across - reload, and require Unity's resolved Git hash to match before succeeding. -- Submodule-to-read-only conversion now requires exact-commit regular root - `package.json` and `package.json.meta` blobs, a matching valid UPM package - identity, and a bounded canonical Unity meta marker before either the project - manifest or source submodule can change. -- Read-only-to-submodule conversion now validates the regular root - `package.json` blob and declared package name directly from Unity's exact - resolved commit before removing the source dependency. -- Submodule installs now require the checked-out `HEAD` to match the exact - commit inspected during preflight and bind root tree-mode validation to that - immutable commit before the transaction can succeed. -- Dependency-aware submodule steps no longer advance from the presentation - snapshot's cached commit alone. A fresh operation- and step-scoped worker - verifies the current origin, exact worktree and staged `.gitmodules` - registration, and repeated parent stage-0 gitlink plus initialized submodule - `HEAD` reads. The terminal stability sequence rechecks commit state, origin, - then commit state again; pending, stale, unstable, truncated, or unconfirmed - evidence fails closed. -- Hardened destructive operations with exact Git-root/worktree/origin checks, - ignored-file protection, cancellable transactions, and recovery ownership. -- Hardened CI and releases so pull-request code cannot access Unity credentials - and published archives are the exact bytes tested in Unity. +- Catalogue discovery now requires regular root `package.json` and + `package.json.meta` blobs from the same commit. Direct URL installation still + permits a valid manifest without the Unity marker, but only after a mandatory + warning. Verified file identities are checked again after installation. +- Installs and conversions are tied to the commit inspected during preflight. + The manager checks the resulting package identity, Git revision, and root file + types before accepting an install or removing the original package source. +- Structural Git output, manifests, meta files, and recovery journals use + bounded strict-UTF-8 reads. Malformed, truncated, linked, replaced, or + oversized data is rejected instead of being treated as repository state. +- Process creation is serialized only across the redirected-pipe setup window, + preventing concurrent commands from inheriting one another's output handles + under Unity's Mono runtime. Normally completed commands then share a bounded + five-second drain window, while inherited or stuck handles still fail closed. +- Submodule completion no longer trusts cached Package Manager state. Fresh + checks bind the parent gitlink, staged and worktree `.gitmodules`, child + origin, and `HEAD` to the current operation. The final stability check repeats + those reads around the origin lookup; `.gitmodules` is capped at 128 KiB. +- Registry fallback for a custom dependency is tied to the complete GitHub + catalogue revision that found no match. If catalogue coverage changes while a + registry request is pending, resolution starts again with current data. +- Destructive operations check dirty and ignored files, exact Git index state, + and cleanup ownership. Rollback removes only state created by the operation; + concurrent changes and ambiguous files are kept with recovery instructions. + Once recovery mutation starts, its closing checks cannot be cancelled. +- Submodule removal verifies the final index, worktree identity, diff, and path + absence. Exact CRLF-to-LF normalization is supported, while linked or + concurrently replaced files are preserved. +- Manifest edits and reload journals use identity-checked replacement and + quarantine paths, so a late writer's bytes are recoverable rather than + deleted. Pull-request code cannot access Unity credentials, and releases + publish the same archive bytes tested in Unity. + +The exact Git proof sequences and recovery boundaries are documented in the +[architecture and safety model](Documentation~/architecture.md). ### Added @@ -105,8 +70,8 @@ and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Git dependencies. Per-user Preferences provide the initial visibility, organization, and install-mode defaults: all repositories, all owners, and Git submodule respectively. -- Package Manager presentation for installed package submodules, including a - **Submodule** tag and **GitHub** source with the existing themed Git icon. +- Package Manager now presents installed submodules with a **Submodule** tag and + a **GitHub** source using the existing themed Git icon. - A fully native **GitHub** page under Package Manager's **Sources** section on Editors with extension-page support. It uses Unity's package list, search, sort, selection, and details UI for installed GitHub submodules and valid UPM @@ -115,12 +80,11 @@ and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). _owner_**, carry **Public** or **Private** repository badges, and use Package Manager's native loading message and spinner while discovery is running. - Native discovered-package details with a **Repository** website link, Git-based - branch selector that prefers `main` and otherwise uses the repository's default - branch, and a primary **Install** action outside Unity's **Extensions** - overflow. Installation uses - the shared validated add transaction and rolls back failed clones or - postcondition checks when process termination and cleanup ownership can be - proven. + branch selector that prefers `main` and otherwise uses the repository's + default branch, and a primary **Install** action outside Unity's **Extensions** + overflow. Installation uses the shared validated add transaction and rolls + back failed clones or postcondition failures when process termination and + cleanup ownership can be proven. - A Package Manager **+ > Install package as Git Submodule...** command that is available from every Package Manager page. A Git-only remote probe discovers branches, the default branch, and the exact root `package.json` name before @@ -180,7 +144,7 @@ and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). GraphQL request, automatically bisects oversized responses, and avoids redundant Package Manager projection, list rebuilds, and package lookup work during loading-only progress updates. Refreshes retain the last completed - catalogue atomically for a bounded safety window, so installed-package + catalogue atomically for up to 15 minutes, so installed-package actions remain available while replacement results load. - Current management now lives exclusively at **Window > Package Management > Package Manager > Sources > GitHub** and in Package Manager's native details, @@ -284,49 +248,52 @@ and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Release validation now behaves consistently on Windows, includes required attribution in package archives, and checks out and revalidates the exact annotated tag without persisting repository credentials. -- Resolve native actions from Package Manager's exact active-page selection +- Resolved native actions from Package Manager's exact active-page selection after package resolves or script reloads, preventing a recycled stale toolbar package from disabling an installed submodule's **Manage** actions. - Reset retained-catalogue inspection throttling with each discovery lifecycle, so an earlier host timestamp cannot postpone expiry after refresh or teardown. -- Make the native GitHub package **Install** action and the Package Manager **+** +- Made the native GitHub package **Install** action and the Package Manager **+** installer use inline confirmation, progress, and error states so they remain responsive in automated GUI Editors where Unity suppresses modal dialogs. -- Block submodule removal when modified, untracked, conflicted, unpushed, staged, +- Blocked submodule removal when modified, untracked, conflicted, unpushed, staged, or otherwise ambiguous work could be lost; recovery metadata is preserved. -- Preserve staged and unstaged `.gitmodules` state by refusing ambiguous parent +- Preserved staged and unstaged `.gitmodules` state by refusing ambiguous parent mutations and validating index locks before changes. -- Accept valid UPM package names containing hyphens or underscores while +- Accepted valid UPM package names containing hyphens or underscores while requiring the exact case-sensitive `Packages/` directory. -- Reject executable Git remote helpers, embedded URL credentials, unsafe browser +- Rejected executable Git remote helpers, embedded URL credentials, unsafe browser schemes, plaintext `http://`/`git://` transports, and mismatched stale submodule metadata. -- Resolve module metadata through Git so remove/re-add works in linked worktrees. -- Validate bounded regular UTF-8 manifests after cloning, reject duplicate - submodule registrations, and discard truncated structural Git output. -- Verify the exact package name, version, and dependency map captured during +- Resolved module metadata through Git so remove/re-add works in linked worktrees. +- Validated bounded regular UTF-8 manifests after cloning, rejected duplicate + submodule registrations, and discarded truncated structural Git output. +- Verified the exact package name, version, and dependency map captured during preflight after each dependency-aware submodule or read-only Git install. Mismatches trigger owned rollback or removal; incomplete cleanup reports that the checkout or `Packages/manifest.json` entry may remain. -- Preserve repository-default branch semantics instead of silently forcing or - displaying `main` when no branch is configured. -- Keep installed navigation usable during optional GitHub failures, prevent - stale GitHub account data after re-authentication, and clamp virtualized list +- Preserved the remote-default branch for direct URL installation when no branch + is configured, instead of silently forcing or displaying `main`. +- Kept installed navigation usable during optional GitHub failures, prevented + stale GitHub account data after re-authentication, and clamped virtualized list scrolling after result changes. -- Finalize recovery journals from worker-owned safety outcomes so a closed +- Finalized recovery journals from worker-owned safety outcomes so a closed EditorWindow or notification exception cannot turn a verified operation into a false unsafe-recovery state. -- Declare the Unity JSON serialization module used by manifest and recovery +- Declared the Unity JSON serialization module used by manifest and recovery journal parsing so minimal Unity projects receive every required module. -- Roll back in-memory user preferences when Unity cannot save `UserSettings`, - and report the failure inside Preferences instead of throwing through IMGUI. -- Count a manually opened first-time welcome page as shown, preventing an +- Rolled back in-memory user preferences when Unity cannot save `UserSettings`, + and reported the failure inside Preferences instead of throwing through IMGUI. +- Counted a manually opened first-time welcome page as shown, preventing an unexpected second automatic presentation on the next window open. -- Keep the notification-safety regression test from writing its intentional +- Kept the notification-safety regression test from writing its intentional callback exception to Unity's Console while preserving production reporting. ## [1.0.0] - 2026-07-12 +This was a development snapshot; no `v1.0.0` tag or GitHub release was +published. + ### Added - Editor-only management for Git submodule packages below `Packages/`. @@ -356,11 +323,11 @@ and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed -- Use GitHub clone URLs instead of REST API endpoint URLs for discovered +- Used GitHub clone URLs instead of REST API endpoint URLs for discovered repositories. -- Prevent stale owner, page, or search responses from replacing newer results. -- Apply installed-state marking and sorting after discovery completes. -- Preserve Windows backslashes in quoted process arguments. +- Prevented stale owner, page, or search responses from replacing newer results. +- Applied installed-state marking and sorting after discovery completed. +- Preserved Windows backslashes in quoted process arguments. ## [0.1.0] - 2025 diff --git a/Documentation~/architecture.md b/Documentation~/architecture.md index 3262b56..bc3b603 100644 --- a/Documentation~/architecture.md +++ b/Documentation~/architecture.md @@ -78,8 +78,9 @@ One bounded GitHub GraphQL response reads both files as regular tree entries from the same default-branch commit. Discovery rejects missing entries, symlinks, non-blobs, binary or truncated content, oversized data, malformed manifests, and meta files without exactly one root `fileFormatVersion: 2` and -one nonzero 32-hexadecimal root GUID. Manifest validation cache entries are -keyed by both blob object IDs so a changed marker cannot reuse stale eligibility. +one nonzero 32-character hexadecimal root GUID. Manifest validation cache +entries are keyed by both blob object IDs so a changed marker cannot reuse stale +eligibility. For a valid repository that is not installed, the projection creates a transient placeholder in Package Manager's in-memory database. It exists only @@ -105,7 +106,7 @@ the corresponding fields. Both files come from the same temporary clone commit. The probe first parses exact NUL-delimited Git tree records, accepts only `100644` or `100755` blob entries, and reads each file by its validated object ID. A valid regular manifest with missing, invalid, or symbolic-link meta remains -eligible only in this explicit URL workflow and adds an unavoidable warning to +eligible only in this explicit URL workflow and adds a mandatory warning to the repository trust confirmation; a symbolic-link manifest is invalid. After a package resolve or script reload, Package Manager can restore its active @@ -160,13 +161,13 @@ Visibility accepts all, public, or private repositories. Organization values use the localized presentation form **Organization - _owner_** while the saved user preference stores only a sanitized GitHub login. -Per-user Preferences provide default visibility, organization, and install mode. +Per-user Preferences set the initial visibility, organization, and install mode. The safe defaults are all repositories, no owner restriction, and **Git -Submodule**. Visibility and organization defaults are applied only when the -native page has no existing filter selection, so a user's current Package -Manager state is not overwritten on refresh. The install-mode default initializes -the selector when a discovered repository is selected. Both confirmation types -remain active until the user explicitly opts out of the eligible prompt. +Submodule**. These defaults apply only when Package Manager has no existing +choice, so refresh preserves the user's current filters. Routine clean-operation +confirmations and dependency-plan prompts remain enabled until the user +explicitly disables them. Warnings about dirty or unverified state cannot be +disabled. ## Dependency Planning @@ -209,7 +210,10 @@ Commands run through `System.Diagnostics.Process` with: - `UseShellExecute = false`; - redirected standard output and error; -- concurrent stream draining; +- concurrent stream draining, with one shared five-second budget for both + readers after a normally completed process exits; +- a narrow process-start gate that prevents redirected pipe handles from being + cross-inherited by concurrently launched commands under Unity's Mono runtime; - bounded output and timeouts; - raw-byte, BOM-independent strict UTF-8 decoding for structural output, with decoder flush at EOF and only a genuine UTF-8 BOM removed; @@ -232,36 +236,45 @@ Before a Git mutation, the utility validates: - a direct destination below `Packages/`; - the expected repository, package manifest, gitlink, and `.gitmodules` state. -The tool refuses mutation outside `Packages/com.author.package`. Plaintext -`http://` and `git://` remotes, embedded credentials, executable remote-helper -syntax, symlinked or oversized manifests, duplicate registrations, and malformed -or truncated structural Git output fail closed. - -Submodule installation validates the cloned root manifest, Git registration, -origin, destination, revision, and branch postconditions, and requires the -worker-side checkout `HEAD` to equal the exact commit whose root metadata was -inspected before success can trigger refresh or reload. Final add verification -reads the staged `.gitmodules` blob and package gitlink together, validates the -immutable registration, and binds them to the initialized child's approved -origin and exact `HEAD`. The regular, non-linked, bounded strict-UTF-8 -worktree `.gitmodules` file must match that staged blob. A terminal -index/`HEAD`, origin, index/`HEAD` sequence rejects stage-only redirects and -late origin or commit swaps; the closing proof also repeats clean-state and -interrupted-operation checks so late package files cannot be imported as a -successful install. Reload reconciliation -retains that required commit evidence but deliberately avoids synchronous Git -processes on Unity's main thread. Read-only installation pins Unity Package -Manager to the same exact inspected commit, then verifies both the exact direct -Git manifest entry and Unity's reported `PackageInfo.git.hash`. For -both modes, the installed root manifest must match the exact package name, -version, and dependency-map fingerprint captured from the selected branch before +The tool accepts only a direct child such as +`Packages/`. Plaintext `http://` and `git://` remotes, +embedded credentials, executable remote-helper syntax, symlinked or oversized +manifests, duplicate registrations, and malformed or truncated structural Git +output fail closed. + +In the descriptions below, a verified file snapshot is one read from a regular, +non-linked file whose size, encoding, bytes, and identity have been checked for +that operation. When Git is authoritative, the snapshot also records the +relevant stage-0 blob or gitlink instead of relying on a mutable worktree read. + +Submodule preflight records the root manifest, destination, repository, branch, +revision, and Git registration it expects to create. After Git finishes, the +worker requires the checkout `HEAD` to match the commit whose root metadata was +inspected. Success cannot trigger refresh or reload until this check passes. + +The closing check reads the staged `.gitmodules` blob and package gitlink in one +index snapshot. It binds that registration to the initialized child's approved +origin and `HEAD`, while a verified worktree snapshot of `.gitmodules` must +be strict UTF-8, no larger than 128 KiB, and match the staged blob. The worker +reads index and `HEAD`, then origin, then index and `HEAD` again. It finally +repeats the clean-state and interrupted-operation checks. A stage-only redirect, +late origin or commit swap, or late package file therefore stops the install +instead of being imported as a successful result. + +Reload reconciliation retains the required commit evidence without running +synchronous Git processes on Unity's main thread. A read-only install pins Unity +Package Manager to the same inspected commit, then checks the direct manifest +entry exactly and compares Unity's reported `PackageInfo.git.hash`. In either +mode, the installed root manifest must exactly match the package name, version, +and dependency-map fingerprint captured from the selected branch before mutation. Catalogue roots and catalogue-resolved GitHub dependencies also carry -the validated meta GUID through the reload-safe install plan and require the -installed root `package.json.meta` to match it. A mismatch rolls back the newly -added submodule or removes the newly added read-only dependency only when cleanup -ownership can be proven. Failed or ambiguous cleanup retains state with an -explicit warning to inspect the package path or `Packages/manifest.json` before -retrying. +the validated meta GUID through the reload-safe plan and require the installed +`package.json.meta` to match it. + +If a post-install identity check fails, the manager rolls back the new submodule +or removes the new read-only dependency only when it can prove cleanup ownership. +Otherwise it preserves the state and tells the user to inspect the package path +or `Packages/manifest.json` before trying again. Verified submodule meta evidence also requires the checked-out commit to retain `package.json.meta` as a regular `100644` or `100755` Git blob. This tree-mode @@ -283,44 +296,49 @@ Only then does conversion record the pinned dependency before removing the verified worktree. Package Manager removal is intercepted so Unity cannot recursively delete a verified submodule as a raw embedded directory. -Read-only dependency edits use byte-exact atomic replacement of -`Packages/manifest.json`. Randomized replacement, displaced, and recovery -siblings are never unlinked after a mutable read. Once cleanup ownership is -confirmed, the sibling is atomically renamed to another unique same-directory -recovery path and retained there, so an in-place or replacement late writer's -bytes remain recoverable at the cleanup boundary. +Read-only dependency edits replace `Packages/manifest.json` atomically and only +when its bytes still match preflight. Randomized replacement, displaced, and +recovery siblings are never unlinked after a mutable read. Once ownership is +confirmed, the manager atomically moves each sibling to a unique recovery path +in the same directory and retains it there. A concurrent writer's bytes therefore +remain recoverable whether it changed the file in place or replaced it. The confirmation preference can suppress only clean routine removal or conversion prompts. Dirty, unpushed, changed, or unverified-state decisions are never silently approved. -Cached local tracking refs are not publication evidence for destructive -removal. For a clean initialized worktree, removal uses bounded Git protocol -queries to require the exact commit on the registered remote and a complete -branch-or-tag advertisement whose tip contains it. Local replacement objects -and grafted ancestry are never trusted. The complete removal assessment is -captured again after the network round trip, and conversion carries the same -path-, URL-, and commit-bound proof into its target-first removal step instead -of issuing that proof twice. - -The destructive path never runs a broad `git rm -f`. It first moves the exact -package worktree and `.gitmodules` inode into project-owned Recovery, then uses -one Git-generated full-object binary patch to remove the exact 160000 gitlink -and update the exact staged `.gitmodules` blob under one Git index lock. A -different staged blob or gitlink rejects the whole patch, and a late writer at -either worktree path is preserved. The desired `.gitmodules` bytes are created -only at an absent path and verified against the final staged blob. The closing -proof rechecks the exact desired index, regular worktree identity, quiet diff, -and package-path absence after test or integration hooks can run. Exact CRLF to -LF normalization is accepted only when the normalized bytes hash to the staged -blob; arbitrary filters and working-tree encodings remain blocked. Failed-add -rollback separately captures the exact add-produced gitlink and staged -`.gitmodules` identity, and proceeds only when removing exactly the target -section reproduces the pre-add baseline. Once Recovery mutation begins, its -postconditions run non-cancellably and every unsafe outcome reports the exact -preserved paths. All authoritative `.gitmodules` reads are regular-file checked -and bounded to 128 KiB so binary patch evidence always fits the process-output -safety envelope. +Cached local tracking refs are not proof that a commit was published. Before +removing a clean initialized worktree, bounded Git protocol queries must find +the commit on the registered remote and obtain a complete branch-or-tag +advertisement whose tip contains it. Local replacement objects and grafted +ancestry are not trusted. The manager repeats the complete removal assessment +after this network round trip. A conversion carries the same path-, URL-, and +commit-bound proof into its target-first removal step instead of issuing the +network proof twice. + +Removal never runs a broad `git rm -f`. It proceeds in this order: + +1. Move the verified package worktree and `.gitmodules` inode into the project's + Recovery directory. +2. Ask Git to create one full-object binary patch that removes the recorded + `160000` gitlink and updates the recorded staged `.gitmodules` blob under one + index lock. A different staged blob or gitlink rejects the entire patch. +3. Create the desired worktree `.gitmodules` only if the path is absent, then + verify its bytes against the final staged blob. A late writer at either + worktree path is preserved. +4. Recheck the desired index, regular worktree identity, quiet diff, and absent + package path after all operation callbacks have finished. + +CRLF-to-LF normalization is accepted only when the normalized bytes hash to the +staged blob. Other filters and working-tree encodings remain blocked. + +Failed-add rollback uses a separate snapshot of the gitlink and staged +`.gitmodules` produced by the add. It proceeds only when removing the target +section reproduces the pre-add baseline. Once Recovery mutation begins, +postconditions run non-cancellably; every unsafe outcome reports the preserved +paths. All authoritative filesystem reads of `.gitmodules` require regular, +non-linked, strict-UTF-8 files and are bounded to 128 KiB, so the runner cannot +truncate the binary patch evidence. ## Discovery State @@ -349,8 +367,10 @@ installation remain available. Search, sorting, visibility filtering, and organization filtering operate on the projected records through Package Manager's native controls. Discovery retains -only the current scan generation, so stale owner, page, or refresh results cannot -replace newer catalogue state. Branch listing remains lazy. +the last successful catalogue while a replacement refresh loads and for up to +15 minutes across quick Package Manager host switches. It retains only the +current scan generation, so stale owner, page, or refresh results cannot replace +newer catalogue state. Branch listing remains lazy. ## Threading and Reload Handoff @@ -373,23 +393,26 @@ or failure is retained across reload until a matching Package Manager details surface or recovery dialog presents it successfully, then consumed so it is not shown twice. -A registered submodule never advances a dependency step from the Package Manager -presentation snapshot's cached commit alone. The coordinator requests a fresh -proof bound to its runtime scope, persisted operation, exact step index, -`Packages/` path, repository URL, and inspected commit. A worker -reads the current origin, requires one matching path/URL/branch section in both -the worktree `.gitmodules` and its immutable stage-0 blob, and repeats the parent -gitlink and initialized submodule `HEAD` checks. The final parent-index read -binds that same `.gitmodules` blob identity and exact gitlink. Each worktree -registration read is one regular, non-linked, strict-UTF-8 file of at most 128 -KiB whose raw Git blob identity equals the staged blob; the terminal proof -repeats this identity check. The terminal stability sequence is index/`HEAD`, -origin, then index/`HEAD` again, preventing an origin-only change from hiding -behind stable commit evidence without reopening the commit-read seam. Pending -proof keeps the step waiting; unstable, mismatched, truncated, invalid-UTF-8, -failed, or unconfirmed process evidence fails closed. Reload, step advancement, -and intervening manager mutation activity retire the proof so another operation -or step cannot reuse it. +A dependency step never advances from Package Manager's cached presentation +commit alone. The coordinator requests a fresh proof tied to its runtime scope, +persisted operation, step index, `Packages/` path, repository URL, +and inspected commit. + +The worker then checks the current origin and requires one matching +path/URL/branch section in both the worktree `.gitmodules` and its stage-0 blob. +It also verifies the parent gitlink and initialized submodule `HEAD`. Every +worktree registration read must be a regular, non-linked, strict-UTF-8 file no +larger than 128 KiB whose raw Git blob identity matches the staged file. + +At the acceptance boundary, the worker reads index and `HEAD`, then origin, then +index and `HEAD` again. The final parent-index read binds the same `.gitmodules` +blob identity and exact gitlink, and the worktree file is verified again. This +order catches an origin-only change even when the commit appears stable. A +pending proof keeps the step waiting; unstable, mismatched, truncated, +invalid-UTF-8, failed, or unconfirmed process evidence fails closed. + +Reload, step advancement, or another manager mutation retires the proof. It +cannot be reused by another operation or step. If a persisted primitive or coordinator record is damaged, or a native completion retains the operation identity but loses its exact package identity, @@ -399,16 +422,16 @@ must inspect the manifest, registered packages, submodule metadata, and parent Git state before restarting the Editor to clear that session-only recovery block. -Every operation has a worker-owned completion outcome: succeeded, failed with a -verified rollback, or failed with repository state requiring inspection. That -safety result finalizes the recovery journal independently of a Package Manager -selection change or notification exception. +The worker records one of three completion outcomes: success, failure with a +verified rollback, or failure that requires repository inspection. That result +finalizes the recovery journal even if Package Manager selection changes or a +notification throws. -Journal evidence is one bounded regular, non-linked strict-UTF-8 snapshot. On -POSIX hosts it is opened nonblocking and without following links, so a FIFO or -late link cannot stall or redirect reload recovery. Replacement and removal -recheck exact file identity; displaced journal bytes are retained under the -project recovery directory rather than deleted, and late writers fail closed. +Journal evidence is a bounded, regular, non-linked strict-UTF-8 snapshot. POSIX +hosts open it without blocking or following links, so a FIFO or late link cannot +stall or redirect reload recovery. Replacement and removal recheck the file's +exact identity. Displaced bytes stay in the project recovery directory, and a +late writer causes the operation to fail closed instead of losing data. ## Failure Handling diff --git a/Documentation~/index.md b/Documentation~/index.md index d55a52e..bf52b4a 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -6,27 +6,19 @@ normal read-only Git dependencies. ## Start Here -1. Follow [Installation](installation.md) to install Git, optionally configure +1. Follow [Installation](installation.md) to install Git, optionally set up GitHub CLI, and add the package. -2. On a validated Editor target—exact Unity `6000.3.22f1` or a Unity - `6000.5.*f1` final release—open **Window > Package Management > Package Manager**, - then select **Sources > GitHub**. Unity's native list, search, - sorting, and details show installed GitHub submodules plus valid UPM packages - discovered incrementally from authenticated personal and organization - repositories. -3. Use Package Manager's native **Filters** control for downloaded status, - visibility, and organization. Select a discovered package, inspect its - **Repository** link, choose a branch, then open **Install** and choose the - submodule or read-only action. Review any missing dependency plan, then choose - **Refresh** whenever you need to rescan. -4. Use an installed package's native **Manage** menu to convert eligible Git - packages or safely uninstall a submodule. The Package Manager **+** menu adds - **Install package as Git Submodule...** for direct URL installation. -5. Open **Preferences > Git Submodule Manager** to configure defaults, show the - standalone Welcome window, or reopen **Sources > GitHub**. -6. Continue with the [User Guide](user-guide.md). -7. Use [Troubleshooting](troubleshooting.md) when a CLI, credential, or - submodule command fails. +2. On a supported Unity version, open **Window > Package Management > Package + Manager**, then select **Sources > GitHub** to browse installed and discovered + GitHub packages. +3. Search or filter the list, choose a package and branch, then use **Install** + to add it as a submodule or read-only package. +4. Use **Manage** to convert an eligible package or uninstall a submodule. For a + repository URL, choose **+ > Install package as Git Submodule...**. +5. Configure defaults or reopen the Welcome window under **Preferences > Git + Submodule Manager**. +6. Continue with the [User Guide](user-guide.md), or use + [Troubleshooting](troubleshooting.md) when an operation fails. ## Documentation @@ -41,15 +33,10 @@ normal read-only Git dependencies. ## Product Boundaries -Git Submodule Manager intentionally manages one workflow: - -- Git repositories; -- represented as editable Git submodules or read-only UPM Git dependencies; -- with editable submodules mounted directly at `Packages/com.author.package`; -- controlled explicitly from the Unity Editor. - -It does not manage Git subtrees, arbitrary project folders, scoped registries, -credentials, or system package installation. +Git Submodule Manager handles root UPM Git repositories in two forms: editable +submodules mounted directly under `Packages/`, and read-only UPM Git +dependencies. It does not manage Git subtrees, arbitrary project folders, +scoped registries, credentials, or system tools. ## License diff --git a/Documentation~/installation.md b/Documentation~/installation.md index 52a4d58..1208a9c 100644 --- a/Documentation~/installation.md +++ b/Documentation~/installation.md @@ -23,8 +23,9 @@ page. Install it outside Unity, then choose **Check Again**. ### GitHub CLI -GitHub CLI is optional but recommended. It enables authenticated user and -organization discovery plus remote `package.json` validation. +GitHub CLI is optional but recommended. It enables authenticated GitHub +catalogue discovery, including inspection of repository manifests and Unity +meta files. ```bash gh --version @@ -41,32 +42,27 @@ accepts or stores a GitHub token. ## First Open -On a validated Editor target—exact Unity `6000.3.22f1` or a Unity -`6000.5.*f1` final release—open **Window > Package Management > Package Manager** -and select **GitHub** under **Sources**. The native package list incrementally -adds valid UPM packages discovered from every repository page owned by the -authenticated user and their visible organizations, alongside installed GitHub -submodules. Native search, sorting, selection, and details work across the -combined list. Select a discovered package, use **Repository** to review its -website, select a branch, then open **Install** and choose **Install as Git -Submodule** or **Install as Read-Only Package**. Choose **Refresh** to rescan. - -If GitHub CLI is missing or authentication fails, installed GitHub submodules +On a supported Unity version, open **Window > Package Management > Package +Manager** and select **GitHub** under **Sources**. Repositories appear +incrementally alongside installed GitHub packages. Select a package, review its +**Repository** link and branch, then choose **Install as Git Submodule** or +**Install as Read-Only Package** from **Install**. Choose **Refresh** to rescan. + +If GitHub CLI is missing or authentication fails, installed GitHub packages remain visible, and direct URL installation remains available from Package Manager's **+ > Install package as Git Submodule...** command. -Direct URL installation accepts a valid root UPM `package.json` even when the -selected branch has no valid root `package.json.meta`. Because npm also uses -`package.json`, that state is shown as an unavoidable Unity-intent warning in -the final trust confirmation. When the probe does validate `package.json.meta`, -the installer binds its GUID to the selected branch and requires the checked-out -file to match before completing the submodule installation. +Direct URL installation may continue when the root `package.json` is valid but +`package.json.meta` is missing or invalid. The final confirmation then warns +that the repository could not be identified automatically as a Unity package. +If the meta file is valid, its GUID is tied to the inspected revision and +checked again after checkout. -The first activation of **Sources > GitHub** shows a small standalone Welcome -window that checks Git, GitHub CLI, and GitHub authentication for the current -user. Its shown flag is stored per user and project under Unity's ignored -`UserSettings/` directory. Reopen it with **Show Welcome** under Unity's -**Preferences > Git Submodule Manager** page. +The first time you open **Sources > GitHub**, a small standalone Welcome window +checks Git, GitHub CLI, and GitHub authentication for the current user. Unity +records that the window has been shown in the ignored, per-user `UserSettings/` +directory. Reopen it with **Show Welcome** under Unity's **Preferences > Git +Submodule Manager** page. That Preferences page repeats the Welcome setup checks with installed versions, authentication status, official install/help actions, and **Check Again**. It @@ -76,18 +72,18 @@ defaults and safety choices: - initial repository visibility (**All Repositories** by default) and organization filters (blank for all owners by default); - initial discovered-package install mode (**Git Submodule** by default); -- whether a complete, unambiguous missing-dependency plan may proceed without - another prompt; -- whether the second confirmation may be skipped for a clean, routine - submodule removal or conversion. +- whether to install missing dependencies automatically when each one has a + single unambiguous source; +- whether to skip the second confirmation for a clean, routine submodule + removal or conversion. -Both confirmation-suppression choices are off by default. Dirty, unpushed, -changed, or unverified-work warnings are safety checks and are never suppressed. +Both options are off by default. Warnings about dirty, unpushed, changed, or +unverified work always appear. ## Add the Package with UPM -Use **Window > Package Management > Package Manager > + > Install package from -git URL...**: +Open **Window > Package Management > Package Manager**, choose **+ > Install +package from git URL...**, and enter the tagged `v2.0.0` release: ```text https://github.com/martincalander/GitSubmoduleManager.git#v2.0.0 @@ -120,12 +116,11 @@ Use the explicit Git command above after cloning a team project. ## Compatibility -The validated Editor targets are exact Unity `6000.3.22f1` and Unity -`6000.5.*f1` final releases. Unity `6000.4` is not currently supported or -claimed as validated. Unity package manifests express a minimum rather than a -disjoint support set, so `package.json` uses `unity: 6000.3` with -`unityRelease: 22f1` to admit the validated 6000.3 target. Those fields do not -declare Unity 6000.4 support; the validated targets above are authoritative. +The supported Editor targets are listed under [Prerequisites](#unity). Unity +package manifests can express a minimum version, but not a disjoint support +set. For that reason, `package.json` uses `unity: 6000.3` with +`unityRelease: 22f1` to admit the supported 6000.3 target. Those fields do not +declare Unity 6000.4 support. | Platform | CLI discovery locations | | --- | --- | @@ -133,10 +128,8 @@ declare Unity 6000.4 support; the validated targets above are authoritative. | macOS | `PATH`, Homebrew on Apple Silicon and Intel, system paths | | Linux | `PATH`, common system paths, `/snap/bin` | -The package uses `System.Diagnostics.Process` without a shell, so command -arguments follow the platform's normal process rules. Paths are normalized for -Git configuration while Windows backslashes are preserved when quoting local -repository locations. +Commands are started directly rather than through a shell. Local paths are +normalized for Git on each platform, including Windows repository locations. Network packages must use HTTPS or SSH. Plaintext `http://` and `git://` transports and URLs containing passwords or access tokens are rejected. @@ -153,10 +146,11 @@ read-only Git dependencies provide **Convert to Submodule**. Review ### Migrating from Git Package Manager -The rename changes the UPM package, assembly, namespace, and legacy public -window type identities. Existing Git URL installations must replace the -dependency key in `Packages/manifest.json`; changing only the tag is not -sufficient: +Version 2.0 renamed the UPM package, assembly, namespace, and old public window +types. Pre-release revisions used `com.essentials.gitpackagemanager`, followed +by `com.martincalander.gitpackagemanager`. Existing Git URL installations must +replace whichever legacy dependency key appears in `Packages/manifest.json`; +changing only the revision is not sufficient: ```json { @@ -166,13 +160,15 @@ sufficient: } ``` -Remove the old `com.martincalander.gitpackagemanager` key and let Unity -regenerate `Packages/packages-lock.json`. Replace `` with a -published tag that includes the Git Submodule Manager identity. The renamed -package begins at `v2.0.0`; the historical `v1.0.0` package predates this rename. +Remove the old `com.essentials.gitpackagemanager` or +`com.martincalander.gitpackagemanager` key and let Unity regenerate +`Packages/packages-lock.json`. Replace `` with a published +tag that includes the Git Submodule Manager identity. No 1.x tag was published +from this repository; `v2.0.0` is the first tag under the current identity. For a submodule installation, also coordinate the parent repository's gitlink -and `.gitmodules` path from +and `.gitmodules` path from either +`Packages/com.essentials.gitpackagemanager` or `Packages/com.martincalander.gitpackagemanager` to `Packages/com.martincalander.gitsubmodulemanager`, then run `git submodule sync --recursive`. Update downstream assembly definition @@ -181,14 +177,14 @@ references from `MartinCalander.GitPackageManager.Editor` to `MartinCalander.GitPackageManager.Editor` to `MartinCalander.GitSubmoduleManager.Editor`. -The former public management-window redirect and package menu were removed. -Current workflows use Unity's native Package Manager surface; integrations -must not depend on the deleted window type. +The former management-window redirect and package menu were removed. Current +workflows use Unity's native Package Manager, so code that referenced the old +window type must be updated. -Serialized editor types carry Unity migration metadata. Per-user preferences -are copied non-destructively to `UserSettings/GitSubmoduleManagerSettings.asset`, -and interrupted-operation state under the legacy Library and SessionState paths -remains recoverable. +Serialized editor data carries Unity's migration metadata. Per-user preferences +are copied without deleting the original to +`UserSettings/GitSubmoduleManagerSettings.asset`, and interrupted-operation +state under the old Library and SessionState paths remains recoverable. After this one-time identity migration, a dependency pinned to a Git tag can be upgraded by changing its tag normally. diff --git a/Documentation~/roadmap.md b/Documentation~/roadmap.md index 04b29b2..ae035d9 100644 --- a/Documentation~/roadmap.md +++ b/Documentation~/roadmap.md @@ -9,7 +9,7 @@ compatibility, and a native Unity Editor experience over feature count. Unity `6000.5.*f1` final patch releases; - expand Windows, macOS, and Linux CI coverage where Unity licensing permits; - improve update-state visibility without performing implicit network work; -- add focused integration tests around temporary Git repositories; +- continue expanding integration coverage with temporary Git repositories; - improve accessibility, keyboard navigation, and narrow-window behavior. ## Candidate Work @@ -27,6 +27,6 @@ compatibility, and a native Unity Editor experience over feature count. - managing subtrees, registries, or arbitrary project folders; - storing GitHub tokens or Git credentials. -The roadmap is directional, not a delivery commitment. Discuss proposals before -starting large implementations so design and compatibility constraints can be -agreed first. +The roadmap is directional rather than a delivery schedule. Before starting a +large change, follow the [contribution guide](../.github/CONTRIBUTING.md) so its +design and compatibility impact can be discussed first. diff --git a/Documentation~/troubleshooting.md b/Documentation~/troubleshooting.md index 7320d42..8f03a5b 100644 --- a/Documentation~/troubleshooting.md +++ b/Documentation~/troubleshooting.md @@ -14,7 +14,7 @@ The package also probes common installation directories on Windows, macOS, and Linux. If the installer finishes but the tool remains unavailable, click **Check -again**. Restart Unity if the operating system updated the GUI application's +Again**. Restart Unity if the operating system updated the GUI application's environment only after launch. ## GitHub Discovery Is Disabled @@ -81,14 +81,15 @@ destination package name, for example: Automatic GitHub catalogue discovery also requires a regular root `package.json.meta` containing `fileFormatVersion: 2` and exactly one nonzero -32-hexadecimal root GUID. Unity normally creates this file when the manifest is -tracked as an asset. The importer section may vary and is not used for -eligibility. A direct URL can still install an otherwise valid root UPM package -without this marker, but the installer shows a mandatory unverified-package -warning. +32-character hexadecimal GUID. Unity normally creates this file when the +manifest is tracked as an asset. The importer section may vary and is not used +for eligibility. A direct URL can still install an otherwise valid root UPM +package without this marker, but the installer shows a mandatory warning that +the Unity marker could not be verified. -Monorepos that keep the UPM package below the repository root are not currently -supported by discovery or installation. +Discovery and the manager's install flows do not support a UPM package below +the repository root. An existing read-only UPM dependency may continue using a +repository subdirectory, but it cannot be converted to a submodule. ## A GitHub Package Is Missing from Sources @@ -100,18 +101,17 @@ updated outside Unity. Discovery publishes only repositories whose root `package.json` contains a valid reverse-domain `name` and SemVer 2.0 `version` and whose sibling -`package.json.meta` has a valid Unity meta header and GUID. A missing, malformed, -unavailable, symlinked, oversized, or nested manifest or marker is intentionally -excluded. Correct the repository or access issue, then choose **Refresh** in -Package Manager to rescan the account and organizations. +`package.json.meta` has a valid Unity meta header and GUID. A repository is left +out if either file is missing, nested, linked, too large, malformed, or +unavailable to the authenticated account. Correct the repository or access +issue, then choose **Refresh** in Package Manager to rescan. ## Dependency Resolution Says GitHub Coverage Is Incomplete -For package names outside `com.unity.*`, configured registries are searched only -after a successful scan of the authenticated user's repositories and every -visible organization proves that GitHub has no matching package. A failed owner -page, coverage warning, or unavailable `package.json` therefore blocks the root -install instead of guessing that registry fallback is safe. +For package names outside `com.unity.*`, GitHub must be scanned completely +before a configured registry can be used as a fallback. A failed organization +scan, coverage warning, or unavailable `package.json` therefore stops the root +install instead of guessing. Restore GitHub CLI authentication and repository access, correct the unavailable manifest if you control it, then choose **Refresh** and retry. If GitHub does @@ -119,14 +119,13 @@ contain the package, that source has priority; a duplicate match, version mismatch, or incomplete repository identity must be corrected rather than bypassed through a registry. -## An Installed Manifest Did Not Match Preflight +## Installed Files Changed During Installation -Dependency-aware installation compares the resulting root `package.json` with -the exact name, version, and dependency map inspected before mutation. For a -catalogue package, it also compares the resulting `package.json.meta` GUID with -the verified marker. When a branch changes during installation or Unity resolves -an unexpected direct Git entry, the new install is rejected and automatic -rollback or removal is attempted only when ownership is proven. +The manager compares the installed `package.json` with the name, version, and +dependencies inspected before it changed the project. For a catalogue package, +it also checks the installed `package.json.meta` GUID. If the branch moves or +Unity resolves a different Git entry, installation stops. Cleanup removes only +state tied to that operation. If the diagnostic says cleanup was incomplete, inspect `git status`, the package path, and `Packages/manifest.json` before retrying. The mismatched checkout or @@ -142,7 +141,7 @@ com.author.package Spaces, uppercase letters, path separators, and traversal segments are rejected. -## An Update Changed Files but Unity Still Shows the Old State +## Git Changed a Submodule but Unity Still Shows the Old State Wait for Unity's package import to finish, then choose **Refresh** in Package Manager. @@ -158,9 +157,9 @@ git submodule status Do not dismiss the recovery warning until you have inspected `git status`, `.gitmodules`, the package path, and any Git or SSH processes that may still be -running. The warning is retained when process termination, rollback, or a -postcondition cannot be proven. Once the repository is safe, open **Preferences -> Git Submodule Manager**, review the retained warning again, choose +running. The warning remains when process termination, rollback, or a final +safety check could not be completed. Once the repository is safe, open +**Preferences > Git Submodule Manager**, review the retained warning again, choose **Acknowledge Inspected Recovery State...**, and refresh Package Manager. ## A Teammate Cannot Clone a Private Package @@ -172,6 +171,5 @@ repository and configure credentials before initializing submodules. ## Reporting a Problem Follow the [support guide](https://github.com/martincalander/GitSubmoduleManager/blob/main/.github/SUPPORT.md) -and include Unity version, operating system, -package commit, CLI versions, the exact operation, and complete sanitized error -text. +and include the Unity version, operating system, package commit, CLI versions, +operation, and complete sanitized error text. diff --git a/Documentation~/user-guide.md b/Documentation~/user-guide.md index 1eabffc..256f5b7 100644 --- a/Documentation~/user-guide.md +++ b/Documentation~/user-guide.md @@ -26,22 +26,20 @@ window once per user and project. It checks: authenticated discovery; - GitHub CLI authentication for `github.com`. -The checks run away from Unity's UI thread. If a tool is missing, use the link -to its official installation guidance, install it outside Unity, and choose -**Check Again**. If GitHub CLI is unauthenticated, copy the displayed -authentication command, complete it in a visible terminal, and check again. -Git Submodule Manager never accepts or stores tokens, passwords, SSH keys, or -credential-helper output. - -The same live setup cards appear under **Preferences > Git Submodule Manager**. -Installed tools show a checkmark and their detected version. Preferences and -Welcome share one probe, so opening both does not start competing GitHub CLI -authentication checks. A cached result refreshes automatically after a short -freshness window; use **Check Again** for an immediate refresh after changing a -tool or its authentication. - -Choose **Open GitHub Package Manager** to continue into Package Manager. Reopen the -window at any time from **Preferences > Git Submodule Manager > Show Welcome**. +The checks run in the background. If a tool is missing, follow the link to its +official installation guide, install it outside Unity, and choose **Check +Again**. If GitHub CLI is unauthenticated, copy the displayed command and run it +in a visible terminal. Git Submodule Manager never accepts or stores tokens, +passwords, SSH keys, or credential-helper output. + +The same setup cards appear under **Preferences > Git Submodule Manager**. +Installed tools show a checkmark and their detected version. Results refresh +automatically after a short interval; use **Check Again** for an immediate +refresh after changing a tool or its authentication. + +Choose **Open GitHub Package Manager** to continue into Package Manager. Reopen +the window at any time from **Preferences > Git Submodule Manager > Show +Welcome**. ## Preferences @@ -59,10 +57,11 @@ Package Manager defaults include: - **Install Mode**: **Git Submodule** (default) or **Read-Only Package**. Visibility and organization defaults are applied only when **Sources > GitHub** -has no existing filter selection. The install-mode default initializes the -selector for a discovered repository. The page also provides **Open GitHub -Package Manager**, setup status, version details, installation guidance, and a -manual **Check Again** action. +has no existing filter selection. The install-mode default is applied when a +discovered repository is selected; the two **Install** menu entries remain +unchecked actions. The page also provides **Open GitHub Package Manager**, setup +status, version details, installation guidance, and a manual **Check Again** +action. Two opt-in workflow settings are available: @@ -70,11 +69,11 @@ Two opt-in workflow settings are available: that a submodule removal or conversion is clean and routine. Warnings about uncommitted, unpushed, changed, or unverified work are never suppressible. - **Install Dependencies Automatically** skips the missing-dependency prompt - only when every missing dependency has exactly one safely resolved source. + only when every missing dependency has exactly one unambiguous source. Ambiguous or unresolved dependencies still stop installation. -Both are off by default, so routine removal/conversion and every non-empty safe -dependency plan continue to ask for confirmation until explicitly changed. +Both are off by default, so routine removals, conversions, and dependency plans +continue to ask for confirmation until you change these settings. On first use after the package rename, an existing `UserSettings/GitPackageManagerSettings.asset` file is copied to the new path @@ -83,22 +82,19 @@ without deleting the original. ## GitHub Discovery and Filters Entering **Sources > GitHub** starts the authenticated catalogue scan and shows -Package Manager's native **Refreshing list...** state and spinner while work is -in progress. Discovery walks repository pages for the current GitHub user and -their visible organizations. Root `package.json` and `package.json.meta` files -are checked together in bounded batches from the same default-branch commit. -Only manifests with a valid reverse-domain UPM package name, SemVer 2.0 version, -and a regular Unity meta file containing `fileFormatVersion: 2` and one nonzero -32-hexadecimal GUID enter the catalogue. The meta marker distinguishes likely -Unity package repositories from ordinary npm repositories; it is an eligibility -signal, not proof that the repository is safe or was authored by Unity. +Package Manager's native **Refreshing list...** state and spinner. Discovery +checks repositories owned by the current GitHub user and their visible +organizations. To appear in the catalogue, a repository needs a root +`package.json` with a valid reverse-domain name and SemVer 2.0 version, plus a +regular root `package.json.meta` from the same default-branch commit. The meta +file must contain `fileFormatVersion: 2` and one nonzero 32-character hexadecimal +GUID. This helps distinguish Unity packages from ordinary npm repositories, but +it does not make a repository trustworthy or official. Results appear incrementally and are grouped under **Organization - _owner_**. -Installed GitHub packages remain in the same list throughout the scan. Two -organizations can load concurrently, while pages within each organization stay -ordered. Choose Package Manager's **Refresh** action to rescan the project and -GitHub; if a scan is active, one replacement refresh starts after its bounded -GitHub reads finish. +Installed GitHub packages remain in the list throughout the scan. Choose Package +Manager's **Refresh** action to rescan the project and GitHub. If a scan is +already active, one replacement scan runs afterward. Use Unity's native **Filters** control in the Package Manager toolbar to narrow the page by: @@ -108,12 +104,11 @@ the page by: - organization: all owners or one **Organization - _owner_** value. **Downloaded** includes both Git submodules and read-only UPM Git dependencies. -Search, sorting, and all filters remain Package Manager-native. Discovery keeps -only the current scan generation so stale owner, page, or refresh results cannot -overwrite the newest state. +Search, sorting, and all filters remain Package Manager-native. Results from an +older scan cannot overwrite a newer refresh. If GitHub CLI is missing or unauthenticated, remote discovery cannot start. -Installed GitHub submodules remain available in the native page, and direct URL +Installed GitHub packages remain available in the native page, and direct URL installation continues to require only Git. ## Install a Discovered Package @@ -130,52 +125,33 @@ Select an uninstalled result in **Sources > GitHub**: 4. Review the repository, revision, install mode, and any dependency plan before confirming. -The selected branch is inspected again with Git before mutation. Its root -`package.json` and `package.json.meta` are read from the same checkout commit. -The submodule mode clones to `Packages/` and validates the root -manifest, Unity meta GUID, Git registration, origin, branch, and package -identity. The read-only mode pins Unity Package Manager to the captured commit, -then requires the registered package's Git hash and exact direct manifest entry -to identify that same commit. In both modes, the -installed root `package.json` must retain the exact package name, version, and -dependency map inspected before installation; catalogue installs must also -retain the verified meta GUID. A mismatched new install is rolled back or -removed only when termination and cleanup ownership can be proven; otherwise -the package warns that the checkout or `Packages/manifest.json` entry may remain -and gives inspection instructions. - -Once mutation begins, the ordered install and its current step survive assembly -reload. An in-flight step is not started twice: the coordinator resumes from -Unity's registered package state and retains the final success or failure until -Package Manager can present it once. Before a Git-submodule step advances, a -background Git check freshly verifies that the exact parent index gitlink, -initialized package `HEAD`, origin, and worktree plus staged `.gitmodules` -registration still match that step's inspected commit, repository, path, and -branch. The worktree registration must be one regular, non-linked, strict-UTF-8 -file no larger than 128 KiB whose raw Git blob identity matches the staged -registration, including at the terminal boundary. Its terminal check reads -commit state, origin, then commit state again so a redirect or checkout during -verification cannot reuse the earlier proof. The displayed submodule cache -alone is never completion proof; pending or unverifiable evidence keeps the -operation from advancing. +Before changing the project, Git resolves the selected branch to a commit and +reads `package.json` and `package.json.meta` from that commit. After installation, +the manager rechecks the package name, version, dependencies, origin, revision, +and Git registration. Catalogue installs also retain the verified meta GUID. +Read-only installs are pinned to the inspected commit; submodule installs are +registered at `Packages/`. + +If project or repository state changes during installation, the operation stops. +The manager removes only state it can prove it created; otherwise it leaves the +state in place and tells you what to inspect. In-progress installs survive +assembly reload without repeating a completed mutation. See the +[architecture and safety model](architecture.md) for the underlying Git +postconditions and reload handoff. ## Missing Dependencies -Before the root package is installed, its declared dependencies are compared -with packages already registered directly or transitively and with sources that -can be resolved safely. Only missing dependencies enter the plan. Unity/default -and configured-registry dependencies remain normal transitive Package Manager -dependencies and are never added directly. Uniquely matched GitHub dependencies -are installed explicitly, leaf-first, in the root package's chosen mode. - -An installed package counts as satisfied only when its complete identity and -exact version match. `com.unity.*` requirements use registry search directly. -For every other name, GitHub has priority: resolution waits for all personal and -visible-organization owners to finish successfully, uses a unique exact GitHub -match when present, and consults configured registries only after that complete -scan proves the package absent. An incomplete scan, owner-coverage warning, -unavailable manifest, duplicate GitHub match, or GitHub version/metadata mismatch -blocks the root install instead of falling through to a registry. +Before installing the root package, the manager checks which declared +dependencies are still missing at the required version. Unity and configured +registry packages remain normal transitive Package Manager dependencies. A +dependency with one matching GitHub source is installed explicitly, leaf-first, +in the same mode as the root package. + +`com.unity.*` requirements are searched in configured registries. Other package +names are checked on GitHub first, across the user's repositories and every +visible organization. Registry fallback is available only after a complete scan +finds no GitHub match. An incomplete scan, inaccessible manifest, duplicate +match, or version or metadata mismatch stops installation instead of guessing. When dependencies are missing, the confirmation lists each requirement and its resolved source. Choose **Install Dependencies & Continue** only after reviewing @@ -183,9 +159,9 @@ that plan. If any requirement has no source or multiple possible sources, the install stops with **Missing Dependencies Need Attention** rather than choosing one automatically. Version-mismatched sources are also blocking. -The Preferences option to install dependencies automatically applies only to a -completed plan in which every missing dependency has one resolved candidate. It -does not bypass unresolved or ambiguous results. +The Preferences option to install dependencies automatically applies only when +every missing dependency has one resolved source. It never bypasses an +unresolved or ambiguous result. ## Install a Submodule from a URL @@ -205,21 +181,20 @@ repository you trust. If the selected branch has a valid UPM manifest but its root `package.json.meta` is missing or invalid, the direct installer remains -available because the user supplied that exact repository. Its confirmation -then includes a non-suppressible warning that the repository could not be -classified automatically as a Unity package. This exception applies only to -explicit direct URLs; it does not admit the repository to the GitHub catalogue. +available because you supplied the repository explicitly. Its confirmation +includes a mandatory warning that the repository could not be identified +automatically as a Unity package. This exception does not add the repository to +the GitHub catalogue. Plaintext `http://` and `git://` transports are rejected. HTTPS, `ssh://`, SCP-style SSH addresses, `file://`, and explicit local paths are supported. Embedded passwords and access tokens are rejected; use Git's credential manager or an SSH agent. -The cloned root manifest must retain the exact name, version, and dependency map -read by the branch probe. When the probe verified a Unity meta GUID, the cloned -`package.json.meta` must retain that GUID too. Missing or mismatched manifests, -meta evidence, and failed Git postconditions trigger safe rollback when -ownership can be proven. +Before installation completes, the checked-out manifest must match the name, +version, and dependencies found by the branch probe. If the probe verified a +Unity meta GUID, the checked-out meta file must match it too. A mismatch stops +the operation; the manager rolls back only state it can tie to that operation. ## Convert Package Source @@ -232,42 +207,41 @@ Select an installed package and open Unity's native **Manage** menu: Submodule**. Its `package.json` must be at the repository root; Git URLs that select a package in a repository subdirectory cannot be converted. -Read-only-to-submodule conversion creates and verifies the submodule before -removing the manifest dependency. It reads the root `package.json` from the -exact commit previously resolved by Unity and requires a regular Git blob whose -manifest declares the selected package name. This immutable tree check cannot be -bypassed when `core.symlinks=false` exposes a symbolic-link blob as a regular -worktree file. Submodule-to-read-only conversion pins the current committed -revision only after its exact regular root `package.json` and -`package.json.meta` Git blobs validate as a matching UPM package and Unity meta -marker. A missing, symbolic-link, malformed, oversized, or mismatched committed -file blocks conversion before the project manifest or source submodule changes. -Conversion inspects local and parent-repository state first. Routine confirmation -can be disabled in Preferences, but dirty or unverified-state warnings always -require attention. +Conversion is target-first: the replacement is created and verified before the +original source is removed. Read-only-to-submodule conversion checks the root +manifest at the commit Unity resolved. Submodule-to-read-only conversion pins +the current committed revision after validating its root manifest and Unity meta +file. Missing, linked, malformed, oversized, or mismatched committed files stop +conversion before the original package changes. + +The manager also checks the package and parent repository for local work. +Routine confirmation can be disabled in Preferences, but dirty or unverified +state always requires attention. See the +[architecture and safety model](architecture.md) for the immutable Git checks. ## Uninstall a Submodule For a verified installed submodule, choose **Manage > Uninstall Submodule**. -Git Submodule Manager uses an exact Git-index transaction instead of Unity's -raw embedded package-directory deletion. It verifies the package path, gitlink, -`.gitmodules` registration, worktree origin, and local state before mutation. -The removed worktree and pre-mutation `.gitmodules` file are moved to -`Library/GitSubmoduleManager/Recovery`; the completion message gives their exact -locations so they can be inspected and deleted later when no longer needed. -Removal finishes only after the desired parent index, regular `.gitmodules` -worktree identity, quiet diff, and package-path absence are rechecked. A late -writer or linked replacement is preserved and reported for recovery. Windows -CRLF worktrees are supported only when exact CRLF-to-LF normalization produces -the staged blob; other filters or encodings remain blocked. +Before making changes, the manager verifies the package path, gitlink, +`.gitmodules` registration, origin, and local state. It then stages the precise +parent-repository change instead of asking Unity to delete the package directory +directly. + +The removed worktree and original `.gitmodules` file are moved to +`Library/GitSubmoduleManager/Recovery`. The completion message lists their +locations so you can inspect and delete them later. If repository state changes +during removal, the manager stops and preserves the unexpected state for +recovery. Modified, untracked, ignored, conflicted, staged, unpushed, local-only, or otherwise ambiguous work is never silently discarded. Review every warning and -the parent repository's staged result before committing. Git object metadata is -retained when possible for recovery and a safe re-add. +the parent repository's staged result before committing. Git metadata is kept +when possible so the submodule can be inspected or safely added again. The +[architecture and safety model](architecture.md) describes the index and +recovery checks in detail. ## Private Repositories -Private repository cloning relies on the user's existing Git credential -manager. Authenticated discovery relies on GitHub CLI. Every teammate and build -machine must independently have access to each private repository. +Private repository cloning relies on the user's existing Git credential manager +or SSH agent. Authenticated discovery relies on GitHub CLI. Every teammate and +build machine must independently have access to each private repository. diff --git a/Editor/Utilities/CliCommandRunner.cs b/Editor/Utilities/CliCommandRunner.cs index 98c4bb5..ea39974 100644 --- a/Editor/Utilities/CliCommandRunner.cs +++ b/Editor/Utilities/CliCommandRunner.cs @@ -740,8 +740,10 @@ internal sealed class ProcessCommandRunner : ICommandRunner { private const int WaitPollIntervalMs = 50; private const int ProcessTreeTerminationTimeoutMs = 5000; - private const int OutputDrainTimeoutMs = 1000; + private const int OutputDrainTimeoutMs = 5000; + private const int ForcedStopOutputDrainTimeoutMs = 1000; private const int MaximumEnumeratedProcesses = 100000; + private static readonly object ProcessStartGate = new object(); private static readonly Encoding StrictUtf8Encoding = new UTF8Encoding(false, true); @@ -789,8 +791,20 @@ public CommandResult Run(CommandSpec spec) var stdOutCompleted = new ManualResetEventSlim(false); var stdErrCompleted = new ManualResetEventSlim(false); - if (!process.Start()) - return Failure($"Failed to start process: {spec.FileName}", resolution.ResolvedPath); + lock (ProcessStartGate) + { + // Unity's Mono creates redirected pipe handles inside + // Process.Start and closes the parent's child-side handles + // only after native process creation returns. Serializing + // that narrow window prevents concurrent children from + // inheriting one another's stdout or stderr handles. + if (!process.Start()) + { + return Failure( + $"Failed to start process: {spec.FileName}", + resolution.ResolvedPath); + } + } processStarted = true; Thread stdOutReader = spec.RequireStrictUtf8StdOut ? StartBoundedStrictUtf8Reader( @@ -819,9 +833,10 @@ public CommandResult Run(CommandSpec spec) stdOutReader, stdErrReader, stdOutCompleted, - stdErrCompleted); - DisposeCompletedEvent(stdOutCompleted); - DisposeCompletedEvent(stdErrCompleted); + stdErrCompleted, + OutputDrainTimeoutMs); + DisposeCompletedEvent(stdOutCompleted, stdOutReader); + DisposeCompletedEvent(stdErrCompleted, stdErrReader); return CreateResult( process.ExitCode, @@ -842,9 +857,10 @@ public CommandResult Run(CommandSpec spec) stdOutReader, stdErrReader, stdOutCompleted, - stdErrCompleted); - DisposeCompletedEvent(stdOutCompleted); - DisposeCompletedEvent(stdErrCompleted); + stdErrCompleted, + ForcedStopOutputDrainTimeoutMs); + DisposeCompletedEvent(stdOutCompleted, stdOutReader); + DisposeCompletedEvent(stdErrCompleted, stdErrReader); // Repository commands require proof for the complete process tree, // which the current cross-platform runner cannot provide after a @@ -1398,58 +1414,74 @@ private static bool TryGetDescendantProcessIds(int rootProcessId, out List UseShellExecute = false, CreateNoWindow = true }; - using var psProcess = Process.Start(startInfo); - if (psProcess == null) - return false; + Process psProcess; + lock (ProcessStartGate) + psProcess = Process.Start(startInfo); + using (psProcess) + { + if (psProcess == null) + return false; - string output = psProcess.StandardOutput.ReadToEnd(); - if (!psProcess.WaitForExit(2000) || psProcess.ExitCode != 0) - return false; + string output = psProcess.StandardOutput.ReadToEnd(); + if (!psProcess.WaitForExit(2000) || psProcess.ExitCode != 0) + return false; - var childrenByParent = new Dictionary>(); - string[] lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - if (lines.Length > MaximumEnumeratedProcesses) - return false; + var childrenByParent = new Dictionary>(); + string[] lines = output.Split( + new[] { '\r', '\n' }, + StringSplitOptions.RemoveEmptyEntries); + if (lines.Length > MaximumEnumeratedProcesses) + return false; - foreach (string line in lines) - { - string[] fields = line.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); - if (fields.Length < 2 || - !int.TryParse(fields[0], out int processId) || - !int.TryParse(fields[1], out int parentProcessId) || - processId <= 1 || processId == Process.GetCurrentProcess().Id) + foreach (string line in lines) { - continue; - } + string[] fields = line.Split( + (char[])null, + StringSplitOptions.RemoveEmptyEntries); + if (fields.Length < 2 || + !int.TryParse(fields[0], out int processId) || + !int.TryParse(fields[1], out int parentProcessId) || + processId <= 1 || + processId == Process.GetCurrentProcess().Id) + { + continue; + } - if (!childrenByParent.TryGetValue(parentProcessId, out List children)) - { - children = new List(); - childrenByParent[parentProcessId] = children; + if (!childrenByParent.TryGetValue( + parentProcessId, + out List children)) + { + children = new List(); + childrenByParent[parentProcessId] = children; + } + children.Add(processId); } - children.Add(processId); - } - - var pending = new Stack(); - pending.Push(rootProcessId); - var seen = new HashSet { rootProcessId }; - while (pending.Count > 0) - { - int parentProcessId = pending.Pop(); - if (!childrenByParent.TryGetValue(parentProcessId, out List children)) - continue; - foreach (int childProcessId in children) + var pending = new Stack(); + pending.Push(rootProcessId); + var seen = new HashSet { rootProcessId }; + while (pending.Count > 0) { - if (!seen.Add(childProcessId)) + int parentProcessId = pending.Pop(); + if (!childrenByParent.TryGetValue( + parentProcessId, + out List children)) + { continue; + } + + foreach (int childProcessId in children) + { + if (!seen.Add(childProcessId)) + continue; - descendants.Add(childProcessId); - pending.Push(childProcessId); + descendants.Add(childProcessId); + pending.Push(childProcessId); + } } - } - return true; + return true; + } } catch { @@ -1503,17 +1535,22 @@ private static bool TryRunTaskKill(int processId) UseShellExecute = false, CreateNoWindow = true }; - using var taskKill = Process.Start(startInfo); - if (taskKill == null) - return false; - - if (!taskKill.WaitForExit(ProcessTreeTerminationTimeoutMs)) + Process taskKill; + lock (ProcessStartGate) + taskKill = Process.Start(startInfo); + using (taskKill) { - TryKillRootProcess(taskKill); - return false; - } + if (taskKill == null) + return false; + + if (!taskKill.WaitForExit(ProcessTreeTerminationTimeoutMs)) + { + TryKillRootProcess(taskKill); + return false; + } - return taskKill.ExitCode == 0; + return taskKill.ExitCode == 0; + } } catch { @@ -1742,44 +1779,67 @@ private static void DrainRedirectedOutput( Thread stdOutReader, Thread stdErrReader, ManualResetEventSlim stdOutCompleted, - ManualResetEventSlim stdErrCompleted) - { - if (!stdOutCompleted.Wait(OutputDrainTimeoutMs)) + ManualResetEventSlim stdErrCompleted, + int outputDrainTimeoutMs) + { + // Both readers have already been draining concurrently while the + // process ran. Give them one shared post-exit budget so a busy + // Editor cannot misclassify a small, complete Git response as + // truncated, without letting inherited pipe handles block forever. + var drainStopwatch = Stopwatch.StartNew(); + DrainRedirectedStream( + process.StandardOutput, + stdOut, + stdOutReader, + stdOutCompleted, + drainStopwatch, + outputDrainTimeoutMs); + DrainRedirectedStream( + process.StandardError, + stdErr, + stdErrReader, + stdErrCompleted, + drainStopwatch, + outputDrainTimeoutMs); + } + + private static void DrainRedirectedStream( + TextReader reader, + BoundedTextBuffer destination, + Thread readerThread, + ManualResetEventSlim completed, + Stopwatch drainStopwatch, + int outputDrainTimeoutMs) + { + int remainingMs = Math.Max( + 0, + Math.Max(0, outputDrainTimeoutMs) - + (int)Math.Min(int.MaxValue, drainStopwatch.ElapsedMilliseconds)); + if (completed.Wait(remainingMs)) + return; + + // A successful root-process exit is not proof that every byte + // reached the reader. Structural callers must fail closed when the + // pipe could not be drained completely. + destination.MarkTruncated(); + try { - // A successful root-process exit is not proof that every byte - // reached the reader. Structural callers must fail closed when - // the pipe could not be drained completely. - stdOut.MarkTruncated(); - try - { - process.StandardOutput.Close(); - } - catch - { - // The process may have closed the stream concurrently. - } - stdOutReader.Join(250); + reader.Close(); } - - if (!stdErrCompleted.Wait(OutputDrainTimeoutMs)) + catch { - stdErr.MarkTruncated(); - try - { - process.StandardError.Close(); - } - catch - { - // The process may have closed the stream concurrently. - } - stdErrReader.Join(250); + // The process may have closed the stream concurrently. } + readerThread.Join(250); } - private static void DisposeCompletedEvent(ManualResetEventSlim completedEvent) + private static void DisposeCompletedEvent( + ManualResetEventSlim completedEvent, + Thread readerThread) { - // Do not dispose an event that an asynchronous stream callback may still signal. - if (completedEvent.IsSet) + // Do not dispose an event until its reader has returned from the + // finally block that signals it. + if (completedEvent.IsSet && readerThread.Join(250)) completedEvent.Dispose(); } diff --git a/NOTICE.md b/NOTICE.md index 1fcbef8..b892cef 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -6,8 +6,9 @@ Copyright (c) 2026 Martin Calander. The project is distributed under the [MIT License](LICENSE.md). The license requires its copyright and permission notice to remain with copies or -substantial portions of the software. If you redistribute this project in -source or binary form, keep `LICENSE.md` and this attribution notice with it. +substantial portions of the software, so include `LICENSE.md` when +redistributing those copies. This notice provides attribution and does not add +conditions to the MIT License. "Unity" is a trademark of Unity Technologies. "GitHub" is a trademark of GitHub, Inc. This project is independent and is not endorsed by either company. diff --git a/README.md b/README.md index a7b0749..cc2ba98 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ Manage Git-hosted Unity packages from Unity's native Package Manager. MIT License

-Git Submodule Manager adds GitHub discovery and Git workflows directly to -Unity's Package Manager. Install root UPM repositories as editable submodules or -normal read-only Git dependencies, and convert eligible packages between the two -modes. +Git Submodule Manager brings Git-hosted Unity packages into Unity's own Package +Manager. Browse GitHub repositories, install a root UPM package as an editable +submodule or read-only Git dependency, and switch eligible packages between the +two modes without leaving the Editor. ## Highlights @@ -53,8 +53,8 @@ Windows, macOS, and Linux. ## Installation -In **Window > Package Management > Package Manager**, choose **+ > Install -package from git URL...** and enter: +Install the tagged `v2.0.0` release from **Window > Package Management > Package +Manager > + > Install package from git URL...**: ```text https://github.com/martincalander/GitSubmoduleManager.git#v2.0.0 @@ -73,13 +73,11 @@ only for development or pre-release testing. menu to install a submodule directly from a secure Git URL or local repository. GitHub catalogue discovery uses `gh`; direct URL probing and package operations -use Git. Automatic catalogue eligibility requires both a valid UPM -`package.json` and a valid Unity `package.json.meta` at the repository root. -Because `package.json` is also used outside Unity, direct URL installation shows -a mandatory warning when the Unity marker is missing or invalid, but still lets -the user install an otherwise valid root UPM package they explicitly trust. -Read-only packages using a repository subdirectory cannot be converted to -submodules. +use Git. Catalogue entries need a valid root UPM `package.json` and Unity +`package.json.meta`, which helps keep ordinary npm repositories out of automatic +discovery. Direct URL installs may proceed without a valid meta file, but only +after a mandatory warning. Read-only dependencies that point to a repository +subdirectory cannot be converted to submodules. ## Safety @@ -88,11 +86,11 @@ submodules. are also supported. - Commands run without a shell, hidden credential prompts are disabled, and the package never installs CLI tools or stores credentials. -- Local, staged, untracked, unpushed, or unverified work is never silently - discarded. Changes require confirmation when they can be assessed safely; +- The manager never silently deletes local, staged, untracked, unpushed, or + unverified work. Changes require confirmation when their state can be checked; ambiguous states are blocked. -- Mutations verify Git and package state, roll back only when cleanup ownership - is proven, and otherwise provide recovery instructions. +- Rollback removes only state that can be tied to the current operation. Anything + uncertain is left in place with recovery instructions. See the [architecture and safety model](Documentation~/architecture.md) and [security policy](https://github.com/martincalander/GitSubmoduleManager/blob/main/.github/SECURITY.md) diff --git a/Tests/Editor/GitSubmoduleManagerSafetyIntegrationTests.cs b/Tests/Editor/GitSubmoduleManagerSafetyIntegrationTests.cs index 6724e5f..564bacb 100644 --- a/Tests/Editor/GitSubmoduleManagerSafetyIntegrationTests.cs +++ b/Tests/Editor/GitSubmoduleManagerSafetyIntegrationTests.cs @@ -333,6 +333,133 @@ public void BoundedOutput_PreservesRecentTailAndReportsTruncation() Assert.That(snapshot, Does.Not.Contain("older")); } + [Test] + public void FastProcessOutput_RepeatedGitCommandsDrainWithoutFalseTruncation() + { + if (!ProcessCommandRunner.TryResolveCommand( + "git", + out ExecutableResolution git)) + { + Assert.Ignore("Git is not installed or could not be resolved on this machine."); + } + + var runner = new ProcessCommandRunner(); + for (int index = 0; index < 128; index++) + { + CommandResult result = runner.Run(new CommandSpec + { + FileName = git.ResolvedPath, + ArgumentList = new[] { "--version" }, + WorkingDirectory = Environment.CurrentDirectory, + TimeoutMs = 5000, + RequireStrictUtf8StdOut = index % 2 == 0 + }); + + Assert.That(result.IsSuccess, Is.True, $"Iteration {index}: {result.StdErr}"); + Assert.That(result.StdOutTruncated, Is.False, $"Iteration {index}"); + Assert.That(result.StdErrTruncated, Is.False, $"Iteration {index}"); + Assert.That(result.StdOut, Does.StartWith("git version"), $"Iteration {index}"); + } + } + + [Test] + public void ConcurrentProcessStarts_DoNotCrossInheritRedirectedPipes() + { + if (!ProcessCommandRunner.TryResolveCommand( + "git", + out ExecutableResolution git)) + { + Assert.Ignore("Git is not installed or could not be resolved on this machine."); + } + + const int commandCount = 24; + using var ready = new CountdownEvent(commandCount); + using var start = new ManualResetEventSlim(false); + var results = new CommandResult[commandCount]; + var workers = new Thread[commandCount]; + for (int index = 0; index < commandCount; index++) + { + int workerIndex = index; + workers[index] = new Thread(() => + { + bool sleeper = workerIndex % 4 == 0; + var spec = sleeper + ? CreateSleeperCommandSpec() + : new CommandSpec + { + FileName = git.ResolvedPath, + ArgumentList = new[] { "--version" }, + WorkingDirectory = Environment.CurrentDirectory, + TimeoutMs = 15000 + }; + ready.Signal(); + start.Wait(); + results[workerIndex] = new ProcessCommandRunner().Run(spec); + }) + { + IsBackground = true, + Name = "Git Submodule Manager concurrent start test" + }; + workers[index].Start(); + } + + bool allWorkersReady = ready.Wait(5000); + start.Set(); + var workerStopped = new bool[commandCount]; + for (int index = 0; index < commandCount; index++) + workerStopped[index] = workers[index].Join(20000); + + Assert.That(allWorkersReady, Is.True, "Workers did not reach the start barrier."); + for (int index = 0; index < commandCount; index++) + Assert.That(workerStopped[index], Is.True, $"Worker {index} did not stop."); + + for (int index = 0; index < commandCount; index++) + { + CommandResult result = results[index]; + Assert.That(result, Is.Not.Null, $"Worker {index}"); + Assert.That(result.IsSuccess, Is.True, $"Worker {index}: {result.StdErr}"); + Assert.That(result.StdOutTruncated, Is.False, $"Worker {index}"); + Assert.That(result.StdErrTruncated, Is.False, $"Worker {index}"); + if (index % 4 != 0) + { + Assert.That( + result.StdOut, + Does.StartWith("git version"), + $"Worker {index}"); + } + } + } + + private static CommandSpec CreateSleeperCommandSpec() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + string commandPrompt = Environment.GetEnvironmentVariable("ComSpec") ?? + Path.Combine( + Environment.GetFolderPath( + Environment.SpecialFolder.System), + "cmd.exe"); + return new CommandSpec + { + FileName = commandPrompt, + ArgumentList = new[] + { + "/d", "/c", "ping 127.0.0.1 -n 7 > nul" + }, + WorkingDirectory = Environment.CurrentDirectory, + TimeoutMs = 15000 + }; + } + + return new CommandSpec + { + FileName = "/bin/sh", + ArgumentList = new[] { "-c", "sleep 6" }, + WorkingDirectory = Environment.CurrentDirectory, + TimeoutMs = 15000 + }; + } + [Test] public void StrictUtf8Output_ReportsInvalidGitBlobBytesWithoutReplacement() { diff --git a/Third Party Notices.md b/Third Party Notices.md index 776382e..6f213d3 100644 --- a/Third Party Notices.md +++ b/Third Party Notices.md @@ -10,11 +10,14 @@ below. Git and GitHub CLI remain external tools supplied separately by the user. - File: `ThirdParty/GitSubmoduleManager.Harmony.dll` - Project: [Harmony](https://github.com/pardeike/Harmony) - License: MIT License -- Copyright: Copyright (c) Andreas Pardeike -- Role: defensive Editor-only patching of Unity Package Manager presentation +- Copyright holder: Andreas Pardeike +- Role: Editor-only patches for specific Unity Package Manager presentation + methods #### MIT License +Copyright (c) 2017 Andreas Pardeike + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights @@ -42,10 +45,9 @@ SOFTWARE. - Creator: Jason Long - Source: [Git logo downloads](https://git-scm.com/community/logos) - License: [Creative Commons Attribution 3.0 Unported](https://creativecommons.org/licenses/by/3.0/) -- Use: the official full-color Git icon for package artwork and one-color - variants recolored to Unity's built-in dark- and light-skin icon values for - the standalone Welcome window, Package Manager Sources row, and source - presentation +- Use: the official full-color Git icon for package artwork. One-color variants + are recolored for Unity's dark and light skins and appear in the Welcome + window, Package Manager Sources row, and source presentation. ## External Tools diff --git a/package.json b/package.json index f5347ba..04ad2d6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "com.martincalander.gitsubmodulemanager", "version": "2.0.0", "displayName": "Git Submodule Manager", - "description": "Reliable, cross-platform Unity Editor tooling for discovering and managing Git submodule packages under Packages/.", + "description": "Manage Git-hosted UPM packages in Unity as editable submodules or read-only dependencies.", "unity": "6000.3", "unityRelease": "22f1", "documentationUrl": "https://github.com/martincalander/GitSubmoduleManager/blob/main/Documentation~/index.md",