diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9057764..884782c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,13 +10,11 @@ on: - 'main' jobs: - release: + build: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 # needed to make sure we get all tags - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: '1.24' @@ -25,13 +23,61 @@ jobs: - name: build run: | + mkdir -p dist GOOS=linux GOARCH=amd64 go build -buildvcs=false -o ./dist/commit-headless-linux-amd64 . GOOS=linux GOARCH=arm64 go build -buildvcs=false -o ./dist/commit-headless-linux-arm64 . - # TODO: Not sure how to determine the current os/arch to select one of the above binaries - # so we're just going to build another one - go build -buildvcs=false -o ./dist/commit-headless . - ./dist/commit-headless version | awk '{print $3}' > ./dist/VERSION.txt + - name: upload artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: binaries + path: dist/ + + verify-binaries: + needs: build + runs-on: ${{ matrix.runner }} + + strategy: + matrix: + include: + - runner: ubuntu-latest + binary: commit-headless-linux-amd64 + - runner: ubuntu-24.04-arm + binary: commit-headless-linux-arm64 + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: download artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: binaries + path: action-template/dist/ + + - name: verify binary + uses: ./action-template + with: + print-version: true + + release: + needs: verify-binaries + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 # needed to make sure we get all tags + + - name: download artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: binaries + path: dist/ + + - name: get version + run: | + chmod +x ./dist/commit-headless-linux-amd64 + ./dist/commit-headless-linux-amd64 version | awk '{print $3}' > ./dist/VERSION.txt echo "Current version: $(cat ./dist/VERSION.txt)" - name: create action branch commit @@ -74,17 +120,12 @@ jobs: --message="action: ${subject}" \ --allow-empty # sometimes we have nothing to change, so this ensures we can still commit - REF=$(git rev-parse HEAD) - echo "sha=${REF}" >> $GITHUB_OUTPUT - echo "Created commit ${REF}" - - name: push commits id: push-commits uses: ./ # use the action defined in the action branch with: branch: action command: push - commits: ${{ steps.create-commit.outputs.sha }} - name: check release tag id: check-tag diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e0e0bd5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,557 @@ +# Integration tests for commit-headless action +name: Test + +permissions: + contents: write + +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: '1.24' + + - name: Run Go tests + run: go test -v ./... + + build: + runs-on: ubuntu-latest + needs: unit-tests + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: '1.24' + + - name: Build binary + run: | + mkdir -p ./action-template/dist + go build -buildvcs=false -o ./action-template/dist/commit-headless-linux-amd64 . + + - name: Upload action + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: action + path: action-template/ + + test-create-branch: + runs-on: ubuntu-latest + needs: build + env: &env + TEST_BRANCH: test/ci-${{ github.run_id }}-${{ github.job }} + steps: + - &checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - &download + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: action + path: action/ + + - &configure-git + name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Create local commits + run: | + echo "New branch content" > new-branch-file.txt + git add new-branch-file.txt + git commit -m "test: create-branch commit" + + - name: Push to new branch + id: push + uses: ./action/ + with: + branch: ${{ env.TEST_BRANCH }} + head-sha: ${{ github.sha }} + create-branch: true + command: push + + - name: Verify output + run: | + if [ -z "${{ steps.push.outputs.pushed_ref }}" ]; then + echo "ERROR: pushed_ref output is empty" + exit 1 + fi + echo "Pushed ref: ${{ steps.push.outputs.pushed_ref }}" + + - name: Verify commit on remote + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -2) + echo "Remote log:" + echo "$log" + + if ! echo "$log" | grep -q "test: create-branch commit"; then + echo "ERROR: commit not found on new branch" + exit 1 + fi + + - &cleanup + name: Cleanup + if: always() + run: git push origin --delete ${{ env.TEST_BRANCH }} || true + + test-push: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git + + - &create-branch + name: Create test branch + run: git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} + + - name: Create local commits + run: | + echo "First change" > test-file.txt + git add test-file.txt + git commit -m "test: first commit" + + echo "Second change" >> test-file.txt + git add test-file.txt + git commit -m "test: second commit" + + - name: Push commits + id: push + uses: ./action/ + with: + branch: ${{ env.TEST_BRANCH }} + command: push + + - name: Verify output + run: | + if [ -z "${{ steps.push.outputs.pushed_ref }}" ]; then + echo "ERROR: pushed_ref output is empty" + exit 1 + fi + echo "Pushed ref: ${{ steps.push.outputs.pushed_ref }}" + + - name: Verify commits on remote + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -3) + echo "Remote log:" + echo "$log" + + if ! echo "$log" | grep -q "test: first commit"; then + echo "ERROR: first commit not found on remote" + exit 1 + fi + if ! echo "$log" | grep -q "test: second commit"; then + echo "ERROR: second commit not found on remote" + exit 1 + fi + + - *cleanup + + test-push-no-op: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git + + - *create-branch + + - name: Sync local with remote + run: | + git fetch origin ${{ env.TEST_BRANCH }} + git reset --hard origin/${{ env.TEST_BRANCH }} + + - name: No changes succeeds + uses: ./action/ + with: + branch: ${{ env.TEST_BRANCH }} + command: push + + - *cleanup + + test-force: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git + + - name: Create test branch and push initial commit + run: | + git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} + + echo "Initial change" > test-file.txt + git add test-file.txt + git commit -m "test: initial commit" + + - name: Push initial commit + uses: ./action/ + with: + branch: ${{ env.TEST_BRANCH }} + command: push + + - name: Record pre-force HEAD + id: pre-force + run: | + git fetch origin ${{ env.TEST_BRANCH }} + echo "head_sha=$(git rev-parse origin/${{ env.TEST_BRANCH }})" >> $GITHUB_OUTPUT + + - name: Simulate rebase + run: | + git reset --hard ${{ github.sha }} + + echo "Rebased change 1" > test-file.txt + git add test-file.txt + git commit -m "test: rebased commit 1" + + echo "Rebased change 2" >> test-file.txt + git add test-file.txt + git commit -m "test: rebased commit 2" + + - name: Force-push rebased commits + id: force-push + uses: ./action/ + with: + branch: ${{ env.TEST_BRANCH }} + head-sha: ${{ github.sha }} + force: true + command: push + + - name: Verify output + run: | + if [ -z "${{ steps.force-push.outputs.pushed_ref }}" ]; then + echo "ERROR: pushed_ref output is empty" + exit 1 + fi + echo "Pushed ref: ${{ steps.force-push.outputs.pushed_ref }}" + + - name: Verify rebased commits on remote + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + new_head=$(git rev-parse origin/${{ env.TEST_BRANCH }}) + old_head="${{ steps.pre-force.outputs.head_sha }}" + + echo "Old HEAD: $old_head" + echo "New HEAD: $new_head" + + if [ "$new_head" = "$old_head" ]; then + echo "ERROR: HEAD unchanged after force push" + exit 1 + fi + + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -3) + echo "Remote log:" + echo "$log" + + if ! echo "$log" | grep -q "test: rebased commit 1"; then + echo "ERROR: rebased commit 1 not found on remote" + exit 1 + fi + if ! echo "$log" | grep -q "test: rebased commit 2"; then + echo "ERROR: rebased commit 2 not found on remote" + exit 1 + fi + + if echo "$log" | grep -q "test: initial commit"; then + echo "ERROR: old pre-rebase commit still present" + exit 1 + fi + + - *cleanup + + test-commit-create-branch: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git + + - name: Stage changes + run: | + echo "New branch via commit" > commit-branch-file.txt + git add commit-branch-file.txt + + - name: Commit to new branch + id: commit + uses: ./action/ + with: + branch: ${{ env.TEST_BRANCH }} + head-sha: ${{ github.sha }} + create-branch: true + message: "test: commit create-branch" + command: commit + + - name: Verify output + run: | + if [ -z "${{ steps.commit.outputs.pushed_ref }}" ]; then + echo "ERROR: pushed_ref output is empty" + exit 1 + fi + echo "Pushed ref: ${{ steps.commit.outputs.pushed_ref }}" + + - name: Verify commit on remote + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -2) + echo "Remote log:" + echo "$log" + + if ! echo "$log" | grep -q "test: commit create-branch"; then + echo "ERROR: commit not found on new branch" + exit 1 + fi + + - *cleanup + + test-commit: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git + + - *create-branch + + - name: Stage changes + run: | + echo "Committed via commit command" > commit-test.txt + git add commit-test.txt + + - name: Push staged changes + id: commit + uses: ./action/ + with: + branch: ${{ env.TEST_BRANCH }} + message: "test: commit command" + command: commit + + - name: Verify output + run: | + if [ -z "${{ steps.commit.outputs.pushed_ref }}" ]; then + echo "ERROR: pushed_ref output is empty" + exit 1 + fi + echo "Pushed ref: ${{ steps.commit.outputs.pushed_ref }}" + + - name: Verify on remote + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -1) + echo "Remote log:" + echo "$log" + + if ! echo "$log" | grep -q "test: commit command"; then + echo "ERROR: commit not found on remote" + exit 1 + fi + + - name: No staged changes succeeds + uses: ./action/ + with: + branch: ${{ env.TEST_BRANCH }} + message: "this should not appear" + command: commit + + - *cleanup + + test-modes: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git + + - *create-branch + + - name: Create executable + run: | + echo '#!/bin/bash' > script.sh + echo 'echo "Hello from script"' >> script.sh + chmod +x script.sh + git add script.sh + git commit -m "test: add executable script" + + - name: Push executable + uses: ./action/ + with: + branch: ${{ env.TEST_BRANCH }} + command: push + + - name: Verify executable bit preserved + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + mode=$(git ls-tree origin/${{ env.TEST_BRANCH }} -- script.sh | awk '{print $1}') + echo "File mode on remote: $mode" + + if [ "$mode" != "100755" ]; then + echo "ERROR: executable bit not preserved, expected 100755 got $mode" + exit 1 + fi + echo "Executable bit preserved successfully" + + - *cleanup + + test-replay: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git + + - name: Create test branch and push unsigned commits + run: | + git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} + + echo "replay test 1" > replay-test.txt + git add replay-test.txt + git commit -m "test: replay commit 1" + + echo "replay test 2" >> replay-test.txt + git add replay-test.txt + git commit -m "test: replay commit 2" + + git push origin HEAD:${{ env.TEST_BRANCH }} + + - name: Record pre-replay HEAD + id: pre-replay + run: | + git fetch origin ${{ env.TEST_BRANCH }} + echo "head_sha=$(git rev-parse origin/${{ env.TEST_BRANCH }})" >> $GITHUB_OUTPUT + + - name: Replay commits as signed + id: replay + uses: ./action/ + with: + branch: ${{ env.TEST_BRANCH }} + since: ${{ github.sha }} + command: replay + + - name: Verify output + run: | + if [ -z "${{ steps.replay.outputs.pushed_ref }}" ]; then + echo "ERROR: pushed_ref output is empty" + exit 1 + fi + echo "Pushed ref: ${{ steps.replay.outputs.pushed_ref }}" + + - name: Verify commits were replayed + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + new_head=$(git rev-parse origin/${{ env.TEST_BRANCH }}) + old_head="${{ steps.pre-replay.outputs.head_sha }}" + + echo "Old HEAD: $old_head" + echo "New HEAD: $new_head" + + if [ "$new_head" = "$old_head" ]; then + echo "ERROR: HEAD unchanged after replay" + exit 1 + fi + + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -3) + echo "Remote log:" + echo "$log" + + if ! echo "$log" | grep -q "test: replay commit 1"; then + echo "ERROR: replay commit 1 not found" + exit 1 + fi + if ! echo "$log" | grep -q "test: replay commit 2"; then + echo "ERROR: replay commit 2 not found" + exit 1 + fi + + echo "Replay test passed: commits were replayed with new hashes" + + - *cleanup + + test-replay-single: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git + + - name: Create test branch and push one unsigned commit + run: | + git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} + + echo "single replay test" > single-replay.txt + git add single-replay.txt + git commit -m "test: single unsigned commit" + git push origin HEAD:${{ env.TEST_BRANCH }} + + - name: Record pre-replay HEAD + id: pre-replay + run: | + git fetch origin ${{ env.TEST_BRANCH }} + echo "head_sha=$(git rev-parse origin/${{ env.TEST_BRANCH }})" >> $GITHUB_OUTPUT + + - name: Replay single commit as signed + id: replay + uses: ./action/ + with: + branch: ${{ env.TEST_BRANCH }} + since: ${{ github.sha }} + command: replay + + - name: Verify + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + new_head=$(git rev-parse origin/${{ env.TEST_BRANCH }}) + old_head="${{ steps.pre-replay.outputs.head_sha }}" + + if [ "$new_head" = "$old_head" ]; then + echo "ERROR: HEAD unchanged after replay" + exit 1 + fi + + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -1) + echo "Remote log: $log" + + if ! echo "$log" | grep -q "test: single unsigned commit"; then + echo "ERROR: single commit not found" + exit 1 + fi + + echo "Single commit replay test passed" + + - *cleanup diff --git a/.gitignore b/.gitignore index d9336fd..607df76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ dist/ commit-headless +.claude/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bb8010e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# AGENTS.md + +This file provides guidance for AI coding agents working with this repository. + +## Project Overview + +This is `commit-headless`, a CLI tool and GitHub Action for created signed remote commits from local +changes via the GitHub REST API. + +The action implementation is in the `action/` branch and action releases are tagged with +`action/VERSION`. The contents of the action releases are prepared from the contents of the +`@./action-template` directory. See `.github/workflows/release.yml` for details on how this works. + +## Permissions + +You are allowed to: + +- Read and modify any file in this repository +- Run any Go commands (`go build`, `go test`, `go mod tidy`, etc.) +- Run `git` commands for version control operations, but you should not make commits or push unless + given explicit permissions +- Run `go mod` commands to update dependencies and tidy + +## Building, Running, and Testing + +You can build, run, or test using the `go` command. For instance `go build .` and `go test -v ./...` + +## Guidelines + +This project has the potential to perform destructive operations on a GitHub repository and care +should be taken. + +When making changes, you should ensure the test suite passes. New code, where possible, should carry +accompanying tests. + +Avoid adding dependencies unless adding the dependency provides a significant gain in readability, +useability, or security. + +User-facing changes should come with updates to `@README.md` as well as the action README in +`@./action-template/README.md`. `@CHANGELOG.md` should be updated with a short summary of changes +taking special care to mention breaking changes. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..684b59d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +## v3.0.0 + +### Breaking Changes + +- **push command no longer accepts commit arguments**: The push command now automatically + determines which local commits need to be pushed by comparing local HEAD with the remote branch + HEAD. Previously, you could specify which commits to push as arguments. If the remote HEAD is not + an ancestor of local HEAD, the push will fail due to diverged history. + +- **commit command no longer accepts file arguments**: The commit command now reads from staged + changes (via `git add`), similar to how `git commit` works. Previously, you had to specify the + list of files to include in the commit. Stage your changes first, then run the command. + +- **Action inputs removed**: + - `commits` input removed from push (commits are now auto-detected) + - `files` input removed from commit (files are now read from staging area) + +### Features + +- **New `replay` command**: Replays existing remote commits as signed commits. Useful when a bot or + action creates unsigned commits that you want to replace with signed versions. The command + fetches the remote, extracts commits since a specified base, recreates them as signed, and + force-updates the branch. + +- **File mode preservation**: Executable bits and other file modes are now preserved when pushing + commits. Previously all files were created with mode `100644`. + +- **GitHub Actions logging**: When running in GitHub Actions, output now uses workflow commands for + better integration: + - Commit operations are grouped for cleaner logs + - Success/failure notices appear in the workflow summary + - Warnings and errors use appropriate annotation levels + +- **REST API**: Switched from GraphQL API to REST API internally, enabling file mode support and + improved error handling. + +### Other Changes + +- Added CI test workflow that runs integration tests on pull requests +- Release workflow now verifies binaries on both amd64 and arm64 before releasing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87b2440..d19191a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,4 +25,20 @@ to be testable. ## Running Tests -Using go test: `go test -v .` +Using go test: `go test -v ./...` + +## Action Releases + +On merge to main, if there's no tagged release for the current version (in `version.go`), a new +tag is created on the action branch. + +The action branch contains prebuilt binaries to avoid Docker-based actions or runtime downloads. +The release workflow uses the built binary to create the action branch commit, providing confidence +that releases work correctly. + +Tags follow the form `action/vVERSION`. See the [release workflow](.github/workflows/release.yml) +for details. + +## Internal Image Releases + +See the internal commit-headless-ci-config repository. diff --git a/README.md b/README.md index 6bc0e1b..899ce1e 100644 --- a/README.md +++ b/README.md @@ -1,148 +1,181 @@ # commit-headless -A binary tool and GitHub Action for creating signed commits from headless workflows +A binary tool and GitHub Action for creating signed commits from headless workflows. -For the Action, please see [the action branch][action-branch] and the associated `action/` -release tags. For example usage, see [Examples](#examples). +`commit-headless` turns local commits into commits on the remote using the GitHub REST API. +When commits are created using the API with a GitHub App or installation token (such as +`github.token` in Actions), they are signed and verified by GitHub. Commits created with a personal +access token or OAuth token will not be signed. -`commit-headless` is focused on turning local commits into signed commits on the remote. It does -this using the GitHub API, more specifically the [createCommitOnBranch][mutation] mutation. When -commits are created using the API (instead of via `git push`), the commits will be signed and -verified by GitHub on behalf of the owner of the credentials used to access the API. +File modes (such as the executable bit) are preserved when pushing commits. +For the GitHub Action, see [the action branch][action-branch] and the associated `action/` release +tags. -*NOTE:* One limitation of creating commits using the GraphQL API is that it does not expose any -mechanism to set or change file modes. It merely takes the file contents, base64 encoded. This means -that if you rely on `commit-headless` to push binary files (or executable scripts), the file in the -resulting commit will not retain that executable bit. - -[mutation]: https://docs.github.com/en/graphql/reference/mutations#createcommitonbranch [action-branch]: https://github.com/DataDog/commit-headless/tree/action -## Usage +## Commands + +- [push](#push) - Push local commits to the remote as signed commits +- [commit](#commit) - Create a signed commit from staged changes +- [replay](#replay) - Re-sign existing remote commits -There are two ways to create signed headless commits with this tool: `push` and `commit`. +All commands require: +- A target repository: `--target/-T owner/repo` +- A branch name: `--branch branch-name` +- A GitHub token in one of: `HEADLESS_TOKEN`, `GITHUB_TOKEN`, or `GH_TOKEN` -Both of these commands take a target owner/repository (eg, `--target/-T DataDog/commit-headless`) -and remote branch name (eg, `--branch bot-branch`) as required flags and expect to find a GitHub -token in one of the following environment variables: +On success, `commit-headless` prints only the SHA of the last commit created, allowing easy capture +in scripts. -- HEADLESS_TOKEN -- GITHUB_TOKEN -- GH_TOKEN +## push -In normal usage, `commit-headless` will print *only* the reference to the last commit created on the -remote, allowing this to easily be captured in a script. +The `push` command automatically determines which local commits need to be pushed by comparing +local HEAD with the remote branch HEAD. It extracts the changed files and commit message from each +local commit and creates corresponding signed commits on the remote. + +The remote commits will have the original commit message, with a "Co-authored-by" trailer for the +original commit author. -More on the specifics for each command below. See also: `commit-headless --help` +Basic usage: -### Specifying the expected head commit + # Push local commits to an existing remote branch + commit-headless push -T owner/repo --branch feature -When creating remote commits via API, `commit-headless` must specify the "expected head sha" of the -remote branch. By default, `commit-headless` will query the GitHub API to get the *current* HEAD -commit of the remote branch and use that as the "expected head sha". This introduces some risk, -especially for active branches or long running jobs, as a new commit introduced after the job starts -will not be considered when pushing the new commits. The commit itself will not be replaced, but the -changes it introduces may be lost. + # Push with a safety check that remote HEAD matches expected value + commit-headless push -T owner/repo --branch feature --head-sha abc123 -For example, consider an auto-formatting job. It runs `gofmt` over the entire codebase. If the job -starts on commit A and formats a file `main.go`, and while the job is running the branch gains -commit B, which adds *new* changes to `main.go`, when the lint job finishes the formatted version of -`main.go` from commit A will be pushed to the remote, and overwrite the changes to `main.go` -introduced in commit B. + # Create a new branch and push local commits to it + commit-headless push -T owner/repo --branch new-feature --head-sha abc123 --create-branch -You can avoid this by specifying `--head-sha`. This will skip auto discovery of the remote branch -HEAD and instead require that the remote branch HEAD matches the value of `--head-sha`. If the -remote branch HEAD does not match `--head-sha`, the push will fail (which is likely what you want). +### Safety check with --head-sha + +By default, `commit-headless` queries the GitHub API to get the current HEAD of the remote branch. +This introduces risk on active branches: if a new commit is pushed after your job starts, your +push will overwrite those changes. + +Specifying `--head-sha` adds a safety check: the push fails if the remote HEAD doesn't match the +expected value. ### Creating a new branch -Note that, by default, both of these commands expect the remote branch to already exist. If your -workflow primarily works on *new* branches, you should additionally add the `--create-branch` flag -and supply a commit hash to use as a branch point via `--head-sha`. With this flag, -`commit-headless` will create the branch on GitHub from that commit hash if it doesn't already -exist. +By default, the target branch must already exist. To create a new branch, use `--create-branch` +with `--head-sha` specifying the branch point: -Example: `commit-headless [flags...] --head-sha=$(git rev-parse main HEAD) --create-branch ...` + commit-headless push -T owner/repo --branch new-feature --head-sha abc123 --create-branch -### commit-headless push +### Force-pushing after rebase -In addition to the required target and branch flags, the `push` command expects a list of commit -hashes as arguments *or* a list of commit hashes *in reverse chronological order (newest first)* -on standard input. +When a branch has been rebased, the local history diverges from the remote and a normal push will +fail. Use `--force` with `--head-sha` to push the rebased commits: -It will iterate over the supplied commits, extract the set of changed files and commit message, then -craft new *remote* commits corresponding to each local commit. + # After rebasing onto updated main: + commit-headless push -T owner/repo --branch feature \ + --head-sha "$(git rev-parse main)" --force -The remote commit will have the original commit message, with "Co-authored-by" trailer for the -original commit author. +The `--head-sha` value is used as the parent of the first pushed commit, bypassing the remote HEAD +check. The branch ref is force-updated even though the push is not a fast-forward. + +`--force` requires `--head-sha` to be set. + +### Diverged history + +The remote HEAD (or `--head-sha` if `--create-branch` or `--force` is set) must be an ancestor of +local HEAD. If it isn't, the push fails to prevent creating broken history. + +## commit + +The `commit` command creates a single signed commit on the remote from your currently staged +changes, similar to `git commit`. Stage your changes with `git add`, then run this command. + +Staged deletions (`git rm`) are also supported. The staged file paths must match the paths on the +remote. + +Basic usage: -You can use `commit-headless push` via: + # Stage changes and commit to remote + git add README.md + commit-headless commit -T owner/repo --branch feature -m "Update docs" - commit-headless push [flags...] HASH1 HASH2 HASH3 ... + # Stage a deletion and a new file + git rm old-file.txt + git add new-file.txt + commit-headless commit -T owner/repo --branch feature -m "Replace old with new" -Or, using git log (note `--oneline`): +### Broadcasting to multiple repositories - git log --oneline main.. | commit-headless push [flags...] +Unlike `push`, the `commit` command does not require any relationship between local and remote +history. This makes it useful for applying the same changes to multiple repositories: -### commit-headless commit + git add config.yml security-policy.md + commit-headless commit -T org/repo1 --branch main -m "Update security policy" + commit-headless commit -T org/repo2 --branch main -m "Update security policy" + commit-headless commit -T org/repo3 --branch main -m "Update security policy" -This command is more geared for creating single commits at a time. It takes a list of files to -commit changes to, and those files will either be updated/added or deleted in a single commit. +## replay -Note that you cannot delete a file without also adding `--force` for safety reasons. +The `replay` command re-signs existing remote commits. This is useful when you have unsigned +commits on a branch (e.g., from a bot or action that doesn't support signed commits) and want to +replace them with signed versions. -Usage example: +The command fetches the remote branch, extracts commits since the specified base, recreates them +as signed commits, and force-updates the branch ref. - # Commit changes to these two files - commit-headless commit [flags...] -- README.md .gitlab-ci.yml +Basic usage: - # Remove a file, add another one, and commit - rm file/i/do/not/want - echo "hello" > hi-there.txt - commit-headless commit [flags...] --force -- hi-there.txt file/i/do/not/want + # Replay all commits since abc123 as signed commits + commit-headless replay -T owner/repo --branch feature --since abc123 - # Commit a change with a custom message - commit-headless commit [flags...] -m"ran a pipeline" -- output.txt + # With safety check that remote HEAD matches expected value + commit-headless replay -T owner/repo --branch feature --since abc123 --head-sha def456 -## Try it! +**Warning:** This command force-pushes to the remote branch. The `--since` commit must be an +ancestor of the branch HEAD. -You can easily try `commit-headless` locally. Create a commit with a different author (to -demonstrate how commit-headless attributes changes to the original author), and run it with a GitHub -token. +## Signature verification -For example, create a commit locally and push it to a new branch using the current branch as the -branch point: +By default, `commit-headless` verifies that each commit created via the API is signed by GitHub. +If a commit is not signed, it retries with exponential backoff (1s, 2s, 4s, ...) up to +`--sign-attempts` times (default: 5). + +Whether GitHub signs a commit depends on the token type: + +- **GitHub App / installation tokens** (including `github.token` in Actions): commits are signed + and verified by GitHub. +- **Personal access tokens / OAuth tokens**: commits are not signed. Set `--sign-attempts 0` to + skip verification when using these token types. + +Even with a valid token, GitHub may occasionally fail to sign a commit. This has been observed +internally and is not consistently reproducible. The retry mechanism exists as a safety net for +these transient failures. + +If all attempts are exhausted without a signed commit, `commit-headless` exits with an error. This +ensures unsigned commits are never silently pushed to the remote. + +## Try it + +Create a local commit and push it to a new branch: ``` cd ~/Code/repo echo "bot commit here" >> README.md git add README.md -git commit --author='A U Thor ' --message="test bot commit" -# Assuming a github token in $GITHUB_TOKEN or $HEADLESS_TOKEN +git commit --author='A U Thor ' -m "test bot commit" + commit-headless push \ - --target=owner/repo \ - --branch=bot-branch \ - --head-sha="$(git rev-parse HEAD^)" \ # use the previous commit as our branch point + -T owner/repo \ + --branch bot-branch \ + --head-sha "$(git rev-parse HEAD^)" \ --create-branch \ - "$(git rev-parse HEAD)" # push the commit we just created + --sign-attempts 0 ``` -## Action Releases +The `--head-sha "$(git rev-parse HEAD^)"` tells commit-headless to create the branch from the +parent of your new commit, so only your new commit gets pushed. -On a merge to main, if there's not already a tagged release for the current version (in -`version.go`), a new tag will be created on the action branch. +Or push to an existing branch: -The action branch contains prebuilt binaries of `commit-headless` to avoid having to use Docker -based (composite) actions, or to avoid having to download the binary when the action runs. - -Because the workflow uses the rendered action (and the built binary) to create the commit to the -action branch we are fairly safe from releasing a broken version of the action. - -Assuming the previous step works, the workflow will then create a tag of the form `action/vVERSION`. - -For more on the action release, see the [workflow](.github/workflows/release.yml). - -## Internal Image Releases +``` +commit-headless push -T owner/repo --branch existing-branch --sign-attempts 0 +``` -See the internal commit-headless-ci-config repository. diff --git a/action-template/README.md b/action-template/README.md index b8e7e11..49afe22 100644 --- a/action-template/README.md +++ b/action-template/README.md @@ -1,67 +1,74 @@ # commit-headless action -NOTE: This branch contains only the action implementation of `commit-headless`. To view the source -code, see the [main](https://github.com/DataDog/commit-headless/tree/main) branch. +This action creates signed and verified commits on GitHub from a workflow. -This action uses `commit-headless` to support creating signed and verified remote commits from a -GitHub action workflow. +For source code and CLI documentation, see the [main branch](https://github.com/DataDog/commit-headless/tree/main). -For more details on how `commit-headless` works, check the main branch link above. +## Commands -## Usage (commit-headless push) +- [push](#push) - Push local commits as signed commits +- [commit](#commit) - Create a signed commit from staged changes +- [replay](#replay) - Re-sign existing remote commits -If your workflow creates multiple commits and you want to push all of them, you can use -`commit-headless push`: +## Inputs -``` +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `command` | Command to run: `push`, `commit`, or `replay` | Yes | | +| `branch` | Target branch name | Yes | | +| `token` | GitHub token | No | `${{ github.token }}` | +| `target` | Target repository (owner/repo) | No | `${{ github.repository }}` | +| `head-sha` | Expected HEAD SHA (safety check) or branch point | No | | +| `create-branch` | Create the branch if it doesn't exist (requires `head-sha`) | No | `false` | +| `force` | Force-update the branch ref (requires `head-sha`) | No | `false` | +| `sign-attempts` | Max attempts to create each commit with a valid signature (0 to skip) | No | `5` | +| `dry-run` | Skip actual remote writes | No | `false` | +| `message` | Commit message (for `commit` command) | No | | +| `author` | Commit author (for `commit` command) | No | github-actions bot | +| `since` | Base commit to replay from (for `replay` command) | No | | +| `working-directory` | Directory to run in | No | | + +## Outputs + +| Output | Description | +|--------|-------------| +| `pushed_ref` | SHA of the last commit created | + +## push + +Push local commits to the remote as signed commits. + +```yaml - name: Create commits - id: create-commits run: | - git config --global user.name "A U Thor" - git config --global user.email "author@example.com" + git config user.name "A U Thor" + git config user.email "author@example.com" echo "new file from my bot" >> bot.txt - git add bot.txt && git commit -m"bot commit 1" + git add bot.txt && git commit -m "bot commit 1" echo "another commit" >> bot.txt - git add bot.txt && git commit -m"bot commit 2" - - # List both commit hashes in reverse order, space separated - echo "commits=\"$(git log "${{ github.sha }}".. --format='%H' | tr '\n' ' ')\"" >> $GITHUB_OUTPUT - - # If you just have a single commit, you can do something like: - # echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - # and then use it in the action via: - # with: - # ... - # commits: ${{ steps.create-commits.outputs.commit }} + git add bot.txt && git commit -m "bot commit 2" - name: Push commits uses: DataDog/commit-headless@action/v%%VERSION%% with: - token: ${{ github.token }} # default - target: ${{ github.repository }} # default branch: ${{ github.ref_name }} command: push - commits: "${{ steps.create-commits.outputs.commits }}" ``` -If you primarily create commits on *new* branches, you'll want to use the `create-branch` option. This -example creates a commit with the current time in a file, and then pushes it to a branch named -`build-timestamp`, creating it from the current commit hash if the branch doesn't exist. +### Creating a new branch -``` +Use `create-branch` with `head-sha` to create the branch if it doesn't exist: + +```yaml - name: Create commits - id: create-commits run: | - git config --global user.name "A U Thor" - git config --global user.email "author@example.com" + git config user.name "A U Thor" + git config user.email "author@example.com" - echo "BUILD-TIMESTAMP-RFC3339: $(date --rfc-3339=s)" > last-build.txt - git add last-build.txt && git commit -m"update build timestamp" - - # Store the created commit as a step output - echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + echo "BUILD-TIMESTAMP: $(date --rfc-3339=s)" > last-build.txt + git add last-build.txt && git commit -m "update build timestamp" - name: Push commits uses: DataDog/commit-headless@action/v%%VERSION%% @@ -70,42 +77,74 @@ example creates a commit with the current time in a file, and then pushes it to head-sha: ${{ github.sha }} create-branch: true command: push - commits: "${{ steps.create-commits.outputs.commit }}" ``` -## Usage (commit-headless commit) +## commit -Some workflows may just have a specific set of files that they change and just want to create a -single commit out of them. For that, you can use `commit-headless commit`: +Create a signed commit from staged changes. Unlike `push`, this doesn't require any relationship +between local and remote history. -``` -- name: Change files - id: change-files +```yaml +- name: Stage changes run: | - echo "updating contents of bot.txt" >> bot.txt - + echo "updating bot.txt" >> bot.txt date --rfc-3339=s >> timestamp + git add bot.txt timestamp - files="bot.txt timestamp" - - # remove an old file if it exists - # commit-headless commit will fail if you attempt to delete a file that doesn't exist on the - # remote (enforced via the GitHub API) - if [[ -f timestamp.old ]]; then - rm timestamp.old - files += " timestamp.old" - fi - - # Record the set of files we want to commit - echo "files=\"${files}\"" >> $GITHUB_OUTPUT + # Deletions work too + git rm -f old-file.txt || true - name: Create commit uses: DataDog/commit-headless@action/v%%VERSION%% with: branch: ${{ github.ref_name }} - author: "A U Thor " # defaults to the github-actions bot account + author: "A U Thor " message: "a commit message" command: commit - files: "${{ steps.create-commits.outputs.files }}" - force: true # default false, needs to be true to allow deletion ``` + +### Broadcasting to multiple repositories + +Apply the same staged changes to multiple repositories: + +```yaml +- name: Stage shared configuration + run: git add config.yml security-policy.md + +- name: Update repo1 + uses: DataDog/commit-headless@action/v%%VERSION%% + with: + target: org/repo1 + branch: main + message: "Update security policy" + command: commit + +- name: Update repo2 + uses: DataDog/commit-headless@action/v%%VERSION%% + with: + target: org/repo2 + branch: main + message: "Update security policy" + command: commit +``` + +## replay + +Re-sign existing remote commits. Useful when an earlier step creates unsigned commits. + +```yaml +- name: Some action that creates unsigned commits + uses: some-org/some-action@v1 + +- name: Replay commits as signed + uses: DataDog/commit-headless@action/v%%VERSION%% + with: + branch: ${{ github.ref_name }} + since: ${{ github.sha }} + command: replay +``` + +The `since` input specifies the base commit (exclusive). All commits after this point are replayed +as signed commits, and the branch is force-updated. + +**Warning:** This command force-pushes to the remote branch. diff --git a/action-template/action.js b/action-template/action.js index d0fa603..45be209 100644 --- a/action-template/action.js +++ b/action-template/action.js @@ -1,5 +1,4 @@ const childProcess = require('child_process') -const crypto = require('crypto') const fs = require('fs') const os = require('os') const process = require('process') @@ -30,13 +29,20 @@ function main() { console.error(`Error making binary executable: ${err.message}`); } + // Handle print-version: just run version command and exit + const printVersion = (process.env["INPUT_PRINT-VERSION"] || "false").toLowerCase(); + if (printVersion === "true") { + const child = childProcess.spawnSync(cmd, ["version"], { stdio: 'inherit' }); + process.exit(child.status || 0); + } + const env = { ...process.env }; env.HEADLESS_TOKEN = process.env.INPUT_TOKEN; const command = process.env.INPUT_COMMAND; - if (!["commit", "push"].includes(command)) { - console.error(`Unknown command '${command}'. Must be one of "commit" or "push".`); + if (!["commit", "push", "replay"].includes(command)) { + console.error(`Unknown command '${command}'. Must be one of "commit", "push", or "replay".`); process.exit(1); } @@ -59,6 +65,14 @@ function main() { if(createBranch.toLowerCase() === "true") { args.push("--create-branch") } + const force = process.env["INPUT_FORCE"] || "false" + if(!["true", "false"].includes(force.toLowerCase())) { + console.error(`Invalid value for force (${force}). Must be one of true or false.`); + process.exit(1); + } + + if(force.toLowerCase() === "true") { args.push("--force") } + const dryrun = process.env["INPUT_DRY-RUN"] || "false" if(!["true", "false"].includes(dryrun.toLowerCase())) { console.error(`Invalid value for dry-run (${dryrun}). Must be one of true or false.`); @@ -67,60 +81,39 @@ function main() { if(dryrun.toLowerCase() === "true") { args.push("--dry-run") } - if (command === "push") { - args.push(...process.env.INPUT_COMMITS.split(/\s+/)); - } else { + const signAttempts = process.env["INPUT_SIGN-ATTEMPTS"] || ""; + if(signAttempts !== "") { args.push("--sign-attempts", signAttempts) } + + if (command === "commit") { const author = process.env["INPUT_AUTHOR"] || ""; const message = process.env["INPUT_MESSAGE"] || ""; if(author !== "") { args.push("--author", author) } if(message !== "") { args.push("--message", message) } + } - const force = process.env["INPUT_FORCE"] || "false" - if(!["true", "false"].includes(force.toLowerCase())) { - console.error(`Invalid value for force (${force}). Must be one of true or false.`); + if (command === "replay") { + const since = process.env["INPUT_SINCE"] || ""; + if(since === "") { + console.error("replay command requires 'since' input"); process.exit(1); } - - if(force.toLowerCase() === "true") { args.push("--force") } - - args.push(...process.env.INPUT_FILES.split(/\s+/)); + args.push("--since", since); } + // The Go binary handles GITHUB_OUTPUT directly and uses stdout for logs + // with workflow commands (grouping, notices, etc.) const child = childProcess.spawnSync(cmd, args, { env: env, cwd: process.env["INPUT_WORKING-DIRECTORY"] || process.cwd(), - // ignore stdin, capture stdout, stream stderr to the parent - stdio: ['ignore', 'pipe', 'inherit'], + stdio: 'inherit', }) - const exitCode = child.status - if (typeof exitCode === 'number') { - if(exitCode === 0) { - const out = child.stdout.toString().trim(); - console.log(`Pushed reference ${out}`); - - const delim = `delim_${crypto.randomUUID()}`; - fs.appendFileSync(process.env.GITHUB_OUTPUT, `pushed_ref<<${delim}${os.EOL}${out}${os.EOL}${delim}`, { encoding: "utf8" }); - process.exit(0); - } - } else { - console.error(`Child process exited uncleanly with signal ${child.signal || "unknown" }`); - if(child.error) { - console.error(` error: ${child.error}`); - } - exitCode = 128; - } - - if(child.stdout) { - // commit-headless should never print anything to stdout *except* the pushed reference, but just - // in case we'll print whatever happens here - console.log("Child process output:"); - console.log(child.stdout.toString().trim()); - console.log(); + if (child.error) { + console.error(`Failed to run commit-headless: ${child.error.message}`); + process.exit(1); } - process.exit(exitCode); - + process.exit(child.status || 0); } if (require.main === module) { diff --git a/action-template/action.yml b/action-template/action.yml index 58399f0..739d1f5 100644 --- a/action-template/action.yml +++ b/action-template/action.yml @@ -1,12 +1,16 @@ -name: Create signed commits out of local commits or a set of changed files. +name: Create signed commits from local commits or staged changes. description: | - This GitHub Action was built specifically to simplify creating signed and verified commits on GitHub. + This GitHub Action simplifies creating signed and verified commits on GitHub. The created commits will be signed, and committer and author attribution will be the owner of the token that was used to create the commit. This is part of the GitHub API and cannot be changed. - However, the original commit author and message will be retained as a "Co-authored-by" trailer and - the message body, respectively. + + For the 'push' command, the original commit author and message will be retained as a + "Co-authored-by" trailer and the message body, respectively. + + For the 'commit' command, use --author and --message to specify commit metadata. This command + reads staged changes (git add) and can be used to broadcast changes to multiple repositories. inputs: token: description: 'GitHub token' @@ -24,29 +28,33 @@ inputs: head-sha: description: 'Expected commit sha of the remote branch, or the commit sha to branch from.' create-branch: - description: 'Create the remote branch, using head-sha as the branch point.' + description: 'Create the remote branch. Requires head-sha as the branch point.' + default: false + force: + description: 'Force-update the branch ref, allowing non-fast-forward updates. Requires head-sha.' default: false command: - description: 'Command to run. One of "commit" or "push"' + description: 'Command to run. One of "commit", "push", or "replay"' required: true + since: + description: 'For replay, the base commit to replay from (exclusive)' + sign-attempts: + description: 'Max attempts to create each commit with a valid signature. Set to 0 to skip verification.' + default: 5 dry-run: description: 'Stop processing just before actually making changes to the remote. Note that the pushed_ref output will be a zeroed commit hash.' default: false - commits: - description: 'For push, the list of commit hashes to push, oldest first' - files: - description: 'For commit, the list of files to include in the commit' - force: - description: 'For commit, set to true to support file deletion' - default: false author: description: 'For commit, the commit author' default: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' message: description: 'For commit, the commit message' + print-version: + # Undocumented: prints version and exits. Used by release workflow. + default: false outputs: - pushed_sha: + pushed_ref: description: 'Commit hash of the last commit created' runs: diff --git a/change.go b/change.go index b0efff2..9954ff4 100644 --- a/change.go +++ b/change.go @@ -5,6 +5,12 @@ import ( "strings" ) +// FileEntry represents a file in a change with its content and mode. +type FileEntry struct { + Content []byte // nil indicates deletion, empty slice indicates empty file + Mode string // git file mode (e.g., "100644", "100755") +} + // Change represents a single change that will be pushed to the remote. type Change struct { hash string @@ -15,9 +21,8 @@ type Change struct { // trailers are lines to add to the end of the body stored as a list to maintain insertion order trailers []string - // entries is a map of path -> content for files modified in the change - // empty or nil content indicates a deleted file - entries map[string][]byte + // entries is a map of path -> FileEntry for files modified in the change + entries map[string]FileEntry } // Splits a commit message on the first blank line diff --git a/cmd_commit.go b/cmd_commit.go index dc797f9..84a92da 100644 --- a/cmd_commit.go +++ b/cmd_commit.go @@ -2,10 +2,7 @@ package main import ( "context" - "errors" "fmt" - "io" - "io/fs" "os" "strings" ) @@ -15,76 +12,94 @@ type CommitCmd struct { Author string `help:"Specify an author using the standard 'A U Thor ' format."` Message []string `short:"m" help:"Specify a commit message. If used multiple times, values are concatenated as separate paragraphs."` - Force bool `help:"Force commiting empty files. Only useful if you know you're deleting a file."` - Files []string `arg:"" help:"Files to commit."` } func (c *CommitCmd) Help() string { return ` -This command can be used to create a single commit on the remote by passing in the names of files. +This command creates a single commit on the remote from the currently staged changes (git add). -It is expected that the paths on disk match to paths on the remote. That is, if you supply -"path/to/file.txt" then the contents of that file on disk will be applied to that same file on the -remote when the commit is created. +It works like 'git commit' - stage your changes first, then run this command to push them as a +signed commit on the remote. -You can also use this to delete files by passing a path to a file that does not exist on disk. Note -that for safety reasons, commit-headless will require an extra flag --force before accepting -deletions. It is an error to attempt to delete a file that does not exist. +The staged file paths must match the paths on the remote. That is, if you stage "path/to/file.txt" +then the contents of that file will be applied to that same path on the remote. -If you pass a path to a file that does not exist on disk without the --force flag, commit-headless -will print an error and exit. +Staged deletions (git rm) are also supported. -You can supply a commit message via --message/-m and an author via --author/-a. If unspecified, +You can supply a commit message via --message/-m and an author via --author. If unspecified, default values will be used. -Examples: - # Commit changes to these two files - commit-headless commit [flags...] -- README.md .gitlab-ci.yml +Unlike 'push', this command does not require any relationship between local and remote history. +This makes it useful for broadcasting the same file changes to multiple repositories: + + git add config.yml security-policy.md + commit-headless commit -T org/repo1 --branch main -m "Update security policy" + commit-headless commit -T org/repo2 --branch main -m "Update security policy" + commit-headless commit -T org/repo3 --branch main -m "Update security policy" - # Remove a file, add another one, and commit - rm file/i/do/not/want - echo "hello" > hi-there.txt - commit-headless commit [flags...] --force -- hi-there.txt file/i/do/not/want +Each target repository can have completely unrelated history - you're applying file contents, +not replaying commits. - # Commit a change with a custom message - commit-headless commit [flags...] -m"ran a pipeline" -- output.txt - ` +Examples: + # Stage changes and commit to remote + git add README.md .gitlab-ci.yml + commit-headless commit -T owner/repo --branch feature -m "Update docs" + + # Stage a deletion and a new file + git rm old-file.txt + git add new-file.txt + commit-headless commit -T owner/repo --branch feature -m "Replace old with new" + + # Stage all changes and commit + git add -A + commit-headless commit -T owner/repo --branch feature -m "Update everything" +` } func (c *CommitCmd) Run() error { + repo := &Repository{path: c.RepoPath} + + entries, err := repo.StagedChanges() + if err != nil { + return err + } + + if len(entries) == 0 { + logger.Notice("No staged changes to commit") + return nil + } + change := Change{ hash: strings.Repeat("0", 40), author: c.Author, message: strings.Join(c.Message, "\n\n"), - entries: map[string][]byte{}, + entries: entries, } - rootfs := os.DirFS(".") + ctx := context.Background() - for _, path := range c.Files { - path = strings.TrimPrefix(path, "./") + token := getToken(os.Getenv) + if token == "" { + return fmt.Errorf("no GitHub token supplied") + } - fp, err := rootfs.Open(path) - if errors.Is(err, fs.ErrNotExist) { - if !c.Force { - return fmt.Errorf("file %q does not exist, but --force was not set", path) - } + client := NewClient(ctx, token, c.Target.Owner(), c.Target.Repository(), c.Branch) + client.dryrun = c.DryRun + client.force = c.Force + client.signAttempts = c.SignAttempts - change.entries[path] = nil - continue - } else if err != nil { - return fmt.Errorf("could not open file %q: %w", path, err) - } + baseCommit, err := c.ResolveBaseCommit(ctx, client) + if err != nil { + return err + } - contents, err := io.ReadAll(fp) + if c.CreateBranch { + remoteSha, err := client.CreateBranch(ctx, baseCommit) if err != nil { - return fmt.Errorf("read %q: %w", path, err) + return err } - - change.entries[path] = contents + baseCommit = remoteSha } - owner, repository := c.Target.Owner(), c.Target.Repository() - - return pushChanges(context.Background(), owner, repository, c.Branch, c.HeadSha, c.CreateBranch, c.DryRun, change) + return pushChanges(ctx, client, baseCommit, change) } diff --git a/cmd_push.go b/cmd_push.go index 85d205e..a9808e6 100644 --- a/cmd_push.go +++ b/cmd_push.go @@ -8,15 +8,12 @@ import ( type PushCmd struct { remoteFlags - RepoPath string `name:"repo-path" default:"." help:"Path to the repository that contains the commits. Defaults to the current directory."` - Commits []string `arg:"" optional:"" help:"Commit hashes to be applied to the target. Defaults to reading a list of commit hashes from standard input."` } func (c *PushCmd) Help() string { return ` -This command should be run when you have commits created locally that you'd like to push to the -remote. You can pass the commit hashes either as space-separated arguments or over standard input -with one commit hash per line. +This command pushes local commits that don't exist on the remote branch. It automatically determines +which commits need to be pushed by comparing local HEAD with the remote branch HEAD. You must provide a GitHub token via the environment in one of the following variables, in preference order: @@ -28,16 +25,31 @@ order: On a successful push, the hash of the last commit pushed will be printed to standard output, allowing you to capture it in a script. All other output is printed to standard error. -For example, to push the most recent three commits: +Example usage: - commit-headless push -T owner/repo --branch branch HEAD HEAD^ HEAD^^ + # Push local commits to an existing remote branch + commit-headless push -T owner/repo --branch feature -Or, to push all commits on the current branch that aren't on the main branch: + # Create a new branch from a specific commit and push local commits + commit-headless push -T owner/repo --branch new-feature --head-sha abc123 --create-branch - git log --oneline main.. | commit-headless push -T owner/repo --branch branch + # Push with a safety check that remote HEAD matches expected value + commit-headless push -T owner/repo --branch feature --head-sha abc123 -When reading commit hashes from standard input, the only requirement is that the commit hash is at -the start of the line, and any other content is separated by at least one whitespace character. + # Force-push rebased commits onto an updated base + commit-headless push -T owner/repo --branch feature --head-sha $(git rev-parse main) --force + +When --head-sha is provided without --create-branch or --force, it acts as a safety check: the push +will fail if the remote branch HEAD doesn't match the expected value. This prevents accidentally +overwriting commits that were pushed after your workflow started. + +When --force is used with --head-sha, the branch ref is updated even if the push is not a +fast-forward. The --head-sha value is used as the parent of the first pushed commit, bypassing the +remote HEAD check. This is useful for re-signing commits after a rebase. + +The remote HEAD (or --head-sha when creating a branch or using --force) must be an ancestor of local +HEAD. If the histories have diverged (and --force is not set), the push will fail. This prevents +creating broken history when the local checkout is out of sync with the remote. Note that the pushed commits will not share the same commit sha, and you should avoid operating on the local checkout after running this command. @@ -51,23 +63,47 @@ pushed commits, you should hard reset the local checkout to the remote version a } func (c *PushCmd) Run() error { - if len(c.Commits) == 0 { - var err error - c.Commits, err = commitsFromStdin(os.Stdin) - if err != nil { - return err - } + ctx := context.Background() + repo := &Repository{path: c.RepoPath} + + token := getToken(os.Getenv) + if token == "" { + return fmt.Errorf("no GitHub token supplied") } - // Convert c.Commits into []Change which we can feed to the remote - repo := &Repository{path: c.RepoPath} + client := NewClient(ctx, token, c.Target.Owner(), c.Target.Repository(), c.Branch) + client.dryrun = c.DryRun + client.force = c.Force + client.signAttempts = c.SignAttempts - changes, err := repo.Changes(c.Commits...) + baseCommit, err := c.ResolveBaseCommit(ctx, client) + if err != nil { + return err + } + + // Find local commits that aren't on the remote (uses the logical base, before branch creation) + commits, err := repo.CommitsSince(baseCommit) + if err != nil { + return err + } + + if len(commits) == 0 { + logger.Noticef("No local commits to push (local HEAD matches remote HEAD %s)", baseCommit) + return nil + } + + changes, err := repo.Changes(commits...) if err != nil { return fmt.Errorf("get changes: %w", err) } - owner, repository := c.Target.Owner(), c.Target.Repository() + if c.CreateBranch { + remoteSha, err := client.CreateBranch(ctx, baseCommit) + if err != nil { + return err + } + baseCommit = remoteSha + } - return pushChanges(context.Background(), owner, repository, c.Branch, c.HeadSha, c.CreateBranch, c.DryRun, changes...) + return pushChanges(ctx, client, baseCommit, changes...) } diff --git a/cmd_replay.go b/cmd_replay.go new file mode 100644 index 0000000..5564448 --- /dev/null +++ b/cmd_replay.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" +) + +type ReplayCmd struct { + baseFlags + Since string `required:"" help:"Base commit to replay from (exclusive). Commits after this will be replayed."` +} + +func (c *ReplayCmd) Help() string { + return ` +This command replays existing remote commits as signed commits. It fetches the remote branch, +extracts commits since the specified base, and recreates them as signed commits using the GitHub +API. The branch ref is then force-updated to point to the new signed commits. + +This is useful when you have unsigned commits on a branch (e.g., from a bot or action that doesn't +support signed commits) and want to replace them with signed versions. + +You must provide a GitHub token via the environment in one of the following variables, in preference +order: + + - HEADLESS_TOKEN + - GITHUB_TOKEN + - GH_TOKEN + +Example usage: + + # Replay all commits since abc123 as signed commits + commit-headless replay -T owner/repo --branch feature --since abc123 + + # With safety check that remote HEAD matches expected value + commit-headless replay -T owner/repo --branch feature --since abc123 --head-sha def456 + +The --since commit must be an ancestor of the branch HEAD. The commits between --since and HEAD +will be replayed as signed commits, and the branch will be force-updated to point to the new HEAD. + +WARNING: This command force-pushes to the remote branch. Use with caution. +` +} + +func (c *ReplayCmd) Run() error { + ctx := context.Background() + repo := &Repository{path: c.RepoPath} + + token := getToken(os.Getenv) + if token == "" { + return fmt.Errorf("no GitHub token supplied") + } + + client := NewClient(ctx, token, c.Target.Owner(), c.Target.Repository(), c.Branch) + client.dryrun = c.DryRun + client.force = true + client.signAttempts = c.SignAttempts + + // Validate remote HEAD against --head-sha if provided + if _, err := c.ValidateRemoteHead(ctx, client); err != nil { + return err + } + + // Fetch the remote branch + logger.Printf("Fetching origin/%s...\n", c.Branch) + if err := repo.Fetch(c.Branch); err != nil { + return err + } + + // Get commits between --since and remote HEAD + remoteRef := fmt.Sprintf("origin/%s", c.Branch) + commits, err := repo.CommitsBetween(c.Since, remoteRef) + if err != nil { + return err + } + + if len(commits) == 0 { + logger.Noticef("No commits to replay (--since %s is already at remote HEAD)", c.Since) + return nil + } + + logger.Printf("Found %d commit(s) to replay\n", len(commits)) + + changes, err := repo.Changes(commits...) + if err != nil { + return fmt.Errorf("get changes: %w", err) + } + + return replayChanges(ctx, client, c.Since, changes...) +} + +// replayChanges pushes changes with force-update enabled. +// baseCommit is used as the parent for the first replayed commit. +func replayChanges(ctx context.Context, client *Client, baseCommit string, changes ...Change) error { + hashes := []string{} + for i := 0; i < len(changes) && i < 10; i++ { + hashes = append(hashes, changes[i].hash) + } + + if len(changes) >= 10 { + hashes = append(hashes, fmt.Sprintf("...and %d more.", len(changes)-10)) + } + + endGroup := logger.Group(fmt.Sprintf("Replaying to %s/%s (branch: %s)", client.owner, client.repo, client.branch)) + defer endGroup() + + logger.Printf("Commits to replay: %s\n", strings.Join(hashes, ", ")) + logger.Printf("Base commit: %s\n", baseCommit) + + pushed, newHead, err := client.PushChanges(ctx, baseCommit, changes...) + if err != nil { + return err + } else if pushed != len(changes) { + return fmt.Errorf("replayed %d of %d changes", pushed, len(changes)) + } + + logger.Noticef("Replayed %d commit(s) as signed: %s", len(changes), client.compareURL(baseCommit, newHead)) + + // Output the new head reference for capture by callers or GitHub Actions + if err := logger.Output("pushed_ref", newHead); err != nil { + return fmt.Errorf("write output: %w", err) + } + + return nil +} diff --git a/git.go b/git.go index 8fb51e3..3a77c1a 100644 --- a/git.go +++ b/git.go @@ -12,7 +12,92 @@ type Repository struct { path string } -// Returns a Change for each supplied commit +// Fetch fetches the specified branch from origin. +func (r *Repository) Fetch(branch string) error { + cmd := exec.Command("git", "fetch", "origin", branch) + cmd.Dir = r.path + if err := cmd.Run(); err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return fmt.Errorf("fetch: %s", strings.TrimSpace(string(ee.Stderr))) + } + return fmt.Errorf("fetch: %w", err) + } + return nil +} + +// CommitsBetween returns the commits between base and head, oldest first. +// This is equivalent to `git rev-list --reverse base..head`. +// Returns an error if base is not an ancestor of head. +func (r *Repository) CommitsBetween(base, head string) ([]string, error) { + // First verify that base is an ancestor of head + cmd := exec.Command("git", "merge-base", "--is-ancestor", base, head) + cmd.Dir = r.path + if err := cmd.Run(); err != nil { + if _, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("%s is not an ancestor of %s (histories have diverged)", base, head) + } + return nil, fmt.Errorf("check ancestry: %w", err) + } + + cmd = exec.Command("git", "rev-list", "--reverse", base+".."+head) + cmd.Dir = r.path + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("list commits: %s", strings.TrimSpace(string(ee.Stderr))) + } + return nil, fmt.Errorf("list commits: %w", err) + } + + var commits []string + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + commits = append(commits, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, err + } + + return commits, nil +} + +// CommitsSince returns the commits between base and HEAD, oldest first. +// This is equivalent to `git rev-list --reverse base..HEAD`. +// Returns an error if base is not an ancestor of HEAD. +func (r *Repository) CommitsSince(base string) ([]string, error) { + // First verify that base is an ancestor of HEAD + cmd := exec.Command("git", "merge-base", "--is-ancestor", base, "HEAD") + cmd.Dir = r.path + if err := cmd.Run(); err != nil { + if _, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("remote HEAD %s is not an ancestor of local HEAD (histories have diverged)", base) + } + return nil, fmt.Errorf("check ancestry: %w", err) + } + + cmd = exec.Command("git", "rev-list", "--reverse", base+"..HEAD") + cmd.Dir = r.path + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("list commits: %s", strings.TrimSpace(string(ee.Stderr))) + } + return nil, fmt.Errorf("list commits: %w", err) + } + + var commits []string + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + commits = append(commits, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, err + } + + return commits, nil +} + +// Returns a Change for each supplied commit hash func (r *Repository) Changes(commits ...string) ([]Change, error) { changes := make([]Change, len(commits)) for i, h := range commits { @@ -25,16 +110,8 @@ func (r *Repository) Changes(commits ...string) ([]Change, error) { return changes, nil } -// Returns a Change for the specific commit +// Returns a Change for the specific commit hash func (r *Repository) changed(commit string) (Change, error) { - // First, make sure the commit looks like a commit hash - // While technically all of our calls would work with references such as HEAD, - // refs/heads/branch, refs/tags/etc we're going to require callers provide things that look like - // commits. - if !hashRegex.MatchString(commit) { - return Change{}, fmt.Errorf("commit %q does not look like a commit, should be at least 4 hexadecimal digits.", commit) - } - parents, author, message, err := r.catfile(commit) if err != nil { return Change{}, err @@ -48,7 +125,7 @@ func (r *Repository) changed(commit string) (Change, error) { hash: commit, message: message, author: author, - entries: map[string][]byte{}, + entries: map[string]FileEntry{}, } change.entries, err = r.changedFiles(commit) @@ -90,8 +167,7 @@ func (r *Repository) catfile(commit string) ([]string, string, string, error) { marker := strings.LastIndex(value, ">") if marker == -1 { // no author, or malformed, so make one up - log("Author is malformed, using a placeholder.\n") - log(" Malformed: %s\n", value) + logger.Warningf("Author is malformed (%s), using placeholder", value) author = "Commit Headless " } else { author = value[:marker+1] @@ -114,9 +190,9 @@ func (r *Repository) catfile(commit string) ([]string, string, string, error) { return parents, author, message, nil } -// Returns the files changed in the given commit, along with their contents -// Deleted files will have an empty value -func (r *Repository) changedFiles(commit string) (map[string][]byte, error) { +// Returns the files changed in the given commit, along with their contents and modes. +// Deleted files will have nil content. +func (r *Repository) changedFiles(commit string) (map[string]FileEntry, error) { cmd := exec.Command("git", "diff-tree", "--no-commit-id", "--name-status", "-r", commit) cmd.Dir = r.path out, err := cmd.Output() @@ -124,7 +200,7 @@ func (r *Repository) changedFiles(commit string) (map[string][]byte, error) { return nil, err } - changes := map[string][]byte{} + changes := map[string]FileEntry{} scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { ln := scanner.Text() @@ -132,21 +208,90 @@ func (r *Repository) changedFiles(commit string) (map[string][]byte, error) { status, value, _ := strings.Cut(ln, "\t") switch { case status == "A" || status == "M": - contents, err := r.fileContent(commit, value) + content, mode, err := r.fileContentAndMode(commit, value) if err != nil { return nil, fmt.Errorf("get content %s:%s: %w", commit, value, err) } - changes[value] = contents + changes[value] = FileEntry{Content: content, Mode: mode} case strings.HasPrefix(status, "R"): // Renames may have a similarity score after the R from, to, _ := strings.Cut(value, "\t") - changes[from] = nil - contents, err := r.fileContent(commit, to) + changes[from] = FileEntry{Content: nil, Mode: ""} + content, mode, err := r.fileContentAndMode(commit, to) if err != nil { return nil, fmt.Errorf("get content %s:%s: %w", commit, to, err) } - changes[to] = contents + changes[to] = FileEntry{Content: content, Mode: mode} + case status == "D": + changes[value] = FileEntry{Content: nil, Mode: ""} + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return changes, nil +} + +func (r *Repository) fileContentAndMode(commit, path string) ([]byte, string, error) { + // Get the file mode from ls-tree + cmd := exec.Command("git", "ls-tree", commit, "--", path) + cmd.Dir = r.path + out, err := cmd.Output() + if err != nil { + return nil, "", fmt.Errorf("ls-tree: %w", err) + } + + // Output format: mode SP type SP hash TAB path + mode := strings.SplitN(string(out), " ", 2)[0] + + // Get the file content + cmd = exec.Command("git", "cat-file", "blob", fmt.Sprintf("%s:%s", commit, path)) + cmd.Dir = r.path + content, err := cmd.Output() + if err != nil { + return nil, "", fmt.Errorf("cat-file: %w", err) + } + + return content, mode, nil +} + +// StagedChanges returns the files staged for commit along with their contents and modes. +// Deleted files have nil content. Returns an empty map if there are no staged changes. +func (r *Repository) StagedChanges() (map[string]FileEntry, error) { + cmd := exec.Command("git", "diff", "--cached", "--name-status") + cmd.Dir = r.path + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("get staged changes: %w", err) + } + + changes := map[string]FileEntry{} + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + ln := scanner.Text() + if ln == "" { + continue + } + + status, path, _ := strings.Cut(ln, "\t") + switch { + case status == "A" || status == "M": + content, mode, err := r.stagedContentAndMode(path) + if err != nil { + return nil, fmt.Errorf("get staged content %s: %w", path, err) + } + changes[path] = FileEntry{Content: content, Mode: mode} + case strings.HasPrefix(status, "R"): // Renames have the form "Rxxx\told\tnew" + from, to, _ := strings.Cut(path, "\t") + changes[from] = FileEntry{Content: nil, Mode: ""} + content, mode, err := r.stagedContentAndMode(to) + if err != nil { + return nil, fmt.Errorf("get staged content %s: %w", to, err) + } + changes[to] = FileEntry{Content: content, Mode: mode} case status == "D": - changes[value] = nil + changes[path] = FileEntry{Content: nil, Mode: ""} } } @@ -157,8 +302,24 @@ func (r *Repository) changedFiles(commit string) (map[string][]byte, error) { return changes, nil } -func (r *Repository) fileContent(commit, path string) ([]byte, error) { - cmd := exec.Command("git", "cat-file", "blob", fmt.Sprintf("%s:%s", commit, path)) +func (r *Repository) stagedContentAndMode(path string) ([]byte, string, error) { + // Get mode from ls-files -s (format: mode SP hash SP stage TAB path) + cmd := exec.Command("git", "ls-files", "-s", "--", path) cmd.Dir = r.path - return cmd.Output() + out, err := cmd.Output() + if err != nil { + return nil, "", fmt.Errorf("ls-files: %w", err) + } + + mode := strings.SplitN(string(out), " ", 2)[0] + + // Get content from the index + cmd = exec.Command("git", "cat-file", "blob", ":"+path) + cmd.Dir = r.path + content, err := cmd.Output() + if err != nil { + return nil, "", fmt.Errorf("cat-file: %w", err) + } + + return content, mode, nil } diff --git a/git_test.go b/git_test.go index 01884e4..e8b342b 100644 --- a/git_test.go +++ b/git_test.go @@ -95,6 +95,255 @@ func TestCommitHashes(t *testing.T) { } } +func TestCommitsSince(t *testing.T) { + tr := testRepo(t) + + // Create a few commits + requireNoError(t, os.WriteFile(tr.path("file1"), []byte("content1"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "first commit") + hash1 := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + requireNoError(t, os.WriteFile(tr.path("file2"), []byte("content2"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "second commit") + hash2 := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + requireNoError(t, os.WriteFile(tr.path("file3"), []byte("content3"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "third commit") + hash3 := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + r := &Repository{path: tr.root} + + t.Run("commits since first", func(t *testing.T) { + commits, err := r.CommitsSince(hash1) + requireNoError(t, err) + if len(commits) != 2 { + t.Fatalf("expected 2 commits, got %d: %v", len(commits), commits) + } + if commits[0] != hash2 || commits[1] != hash3 { + t.Errorf("expected [%s, %s], got %v", hash2, hash3, commits) + } + }) + + t.Run("commits since second", func(t *testing.T) { + commits, err := r.CommitsSince(hash2) + requireNoError(t, err) + if len(commits) != 1 { + t.Fatalf("expected 1 commit, got %d: %v", len(commits), commits) + } + if commits[0] != hash3 { + t.Errorf("expected [%s], got %v", hash3, commits) + } + }) + + t.Run("commits since HEAD (none)", func(t *testing.T) { + commits, err := r.CommitsSince(hash3) + requireNoError(t, err) + if len(commits) != 0 { + t.Errorf("expected no commits, got %v", commits) + } + }) + + t.Run("invalid base", func(t *testing.T) { + _, err := r.CommitsSince("nonexistent-ref-12345") + if err == nil { + t.Error("expected error for invalid reference") + } + }) + + t.Run("diverged history", func(t *testing.T) { + // Create a separate branch with different history + tr.git("checkout", "-b", "other-branch", hash1) + requireNoError(t, os.WriteFile(tr.path("other-file"), []byte("other"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "commit on other branch") + otherHash := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + // Go back to main branch + tr.git("checkout", "-") + + // otherHash is not an ancestor of HEAD (hash3) + _, err := r.CommitsSince(otherHash) + if err == nil { + t.Error("expected error for diverged history") + } + if !strings.Contains(err.Error(), "not an ancestor") { + t.Errorf("expected 'not an ancestor' error, got: %v", err) + } + }) +} + +func TestCommitsBetween(t *testing.T) { + tr := testRepo(t) + + // Create a few commits + requireNoError(t, os.WriteFile(tr.path("file1"), []byte("content1"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "first commit") + hash1 := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + requireNoError(t, os.WriteFile(tr.path("file2"), []byte("content2"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "second commit") + hash2 := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + requireNoError(t, os.WriteFile(tr.path("file3"), []byte("content3"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "third commit") + hash3 := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + r := &Repository{path: tr.root} + + t.Run("commits between first and third", func(t *testing.T) { + commits, err := r.CommitsBetween(hash1, hash3) + requireNoError(t, err) + if len(commits) != 2 { + t.Fatalf("expected 2 commits, got %d: %v", len(commits), commits) + } + if commits[0] != hash2 || commits[1] != hash3 { + t.Errorf("expected [%s, %s], got %v", hash2, hash3, commits) + } + }) + + t.Run("commits between first and second", func(t *testing.T) { + commits, err := r.CommitsBetween(hash1, hash2) + requireNoError(t, err) + if len(commits) != 1 { + t.Fatalf("expected 1 commit, got %d: %v", len(commits), commits) + } + if commits[0] != hash2 { + t.Errorf("expected [%s], got %v", hash2, commits) + } + }) + + t.Run("commits between same commit (none)", func(t *testing.T) { + commits, err := r.CommitsBetween(hash3, hash3) + requireNoError(t, err) + if len(commits) != 0 { + t.Errorf("expected no commits, got %v", commits) + } + }) + + t.Run("diverged history", func(t *testing.T) { + // Create a separate branch with different history + tr.git("checkout", "-b", "other-branch", hash1) + requireNoError(t, os.WriteFile(tr.path("other-file"), []byte("other"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "commit on other branch") + otherHash := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + // Go back to main branch + tr.git("checkout", "-") + + // otherHash is not an ancestor of hash3 + _, err := r.CommitsBetween(otherHash, hash3) + if err == nil { + t.Error("expected error for diverged history") + } + if !strings.Contains(err.Error(), "not an ancestor") { + t.Errorf("expected 'not an ancestor' error, got: %v", err) + } + }) +} + +func TestStagedChanges(t *testing.T) { + tr := testRepo(t) + + // Create initial commit + requireNoError(t, os.WriteFile(tr.path("existing.txt"), []byte("original"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "initial") + + r := &Repository{path: tr.root} + + t.Run("no staged changes", func(t *testing.T) { + changes, err := r.StagedChanges() + requireNoError(t, err) + if len(changes) != 0 { + t.Errorf("expected empty changes, got %d", len(changes)) + } + }) + + t.Run("staged addition", func(t *testing.T) { + requireNoError(t, os.WriteFile(tr.path("new.txt"), []byte("new content"), 0o644)) + tr.git("add", "new.txt") + + changes, err := r.StagedChanges() + requireNoError(t, err) + + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } + if string(changes["new.txt"].Content) != "new content" { + t.Errorf("unexpected content: %q", changes["new.txt"].Content) + } + if changes["new.txt"].Mode != "100644" { + t.Errorf("unexpected mode: %q", changes["new.txt"].Mode) + } + + // Cleanup + tr.git("reset", "HEAD", "new.txt") + os.Remove(tr.path("new.txt")) + }) + + t.Run("staged executable", func(t *testing.T) { + requireNoError(t, os.WriteFile(tr.path("script.sh"), []byte("#!/bin/bash\necho hello"), 0o755)) + tr.git("add", "script.sh") + + changes, err := r.StagedChanges() + requireNoError(t, err) + + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } + if changes["script.sh"].Mode != "100755" { + t.Errorf("expected executable mode 100755, got %q", changes["script.sh"].Mode) + } + + // Cleanup + tr.git("reset", "HEAD", "script.sh") + os.Remove(tr.path("script.sh")) + }) + + t.Run("staged modification", func(t *testing.T) { + requireNoError(t, os.WriteFile(tr.path("existing.txt"), []byte("modified"), 0o644)) + tr.git("add", "existing.txt") + + changes, err := r.StagedChanges() + requireNoError(t, err) + + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } + if string(changes["existing.txt"].Content) != "modified" { + t.Errorf("unexpected content: %q", changes["existing.txt"].Content) + } + + // Cleanup - restore file to original state + tr.git("checkout", "HEAD", "--", "existing.txt") + }) + + t.Run("staged deletion", func(t *testing.T) { + tr.git("rm", "-f", "existing.txt") + + changes, err := r.StagedChanges() + requireNoError(t, err) + + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } + if changes["existing.txt"].Content != nil { + t.Errorf("expected nil for deletion, got %q", changes["existing.txt"].Content) + } + + // Cleanup - restore file + tr.git("reset", "HEAD", "existing.txt") + tr.git("checkout", "existing.txt") + }) +} + func TestChangedFiles(t *testing.T) { // First, prep the test repository tr := testRepo(t) @@ -133,13 +382,13 @@ func TestChangedFiles(t *testing.T) { t.Fatalf("expected changed files to be 'to-delete' and 'to-empty', got %q", keys) } - if change.entries["to-empty"] == nil { - t.Log("expected to-empty to be empty, not nil") + if change.entries["to-empty"].Content == nil { + t.Log("expected to-empty to have empty content, not nil") t.Fail() } - if change.entries["to-delete"] != nil { - t.Logf("expected to-delete to be nil, got %q", change.entries["to-delete"]) + if change.entries["to-delete"].Content != nil { + t.Logf("expected to-delete to have nil content, got %q", change.entries["to-delete"].Content) t.Fail() } } diff --git a/github.go b/github.go index 8d206a1..08b52c9 100644 --- a/github.go +++ b/github.go @@ -1,321 +1,245 @@ package main import ( - "bytes" "context" - "encoding/json" "errors" "fmt" "net/http" "strings" + "time" + "github.com/google/go-github/v81/github" "golang.org/x/oauth2" ) var ErrNoRemoteBranch = errors.New("branch does not exist on the remote") +// RepositoriesAPI defines the subset of github.RepositoriesService methods needed by this project. +type RepositoriesAPI interface { + GetBranch(ctx context.Context, owner, repo, branch string, maxRedirects int) (*github.Branch, *github.Response, error) +} + +// GitAPI defines the subset of github.GitService methods needed by this project. +type GitAPI interface { + CreateRef(ctx context.Context, owner, repo string, ref github.CreateRef) (*github.Reference, *github.Response, error) + GetCommit(ctx context.Context, owner, repo, sha string) (*github.Commit, *github.Response, error) + CreateBlob(ctx context.Context, owner, repo string, blob github.Blob) (*github.Blob, *github.Response, error) + CreateTree(ctx context.Context, owner, repo, baseTree string, entries []*github.TreeEntry) (*github.Tree, *github.Response, error) + CreateCommit(ctx context.Context, owner, repo string, commit github.Commit, opts *github.CreateCommitOptions) (*github.Commit, *github.Response, error) + UpdateRef(ctx context.Context, owner, repo, ref string, updateRef github.UpdateRef) (*github.Reference, *github.Response, error) +} + // Client provides methods for interacting with a remote repository on GitHub type Client struct { - httpC *http.Client + repos RepositoriesAPI + git GitAPI owner string repo string branch string - dryrun bool - - // Used for testing purposes - baseURL string + dryrun bool + force bool + signAttempts int } // NewClient returns a Client configured to make GitHub requests for branch owned by owner/repo on // GitHub using the oauth token in token. func NewClient(ctx context.Context, token, owner, repo, branch string) *Client { - tokensrc := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: token}, - ) + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + httpC := oauth2.NewClient(ctx, ts) + ghClient := github.NewClient(httpC) - httpC := oauth2.NewClient(ctx, tokensrc) return &Client{ - httpC: httpC, - owner: owner, repo: repo, branch: branch, - baseURL: "https://api.github.com", + repos: ghClient.Repositories, + git: ghClient.Git, + owner: owner, + repo: repo, + branch: branch, } } -func (c *Client) branchURL() string { - return fmt.Sprintf("%s/repos/%s/%s/branches/%s", c.baseURL, c.owner, c.repo, c.branch) -} - -func (c *Client) refsURL() string { - return fmt.Sprintf("%s/repos/%s/%s/git/refs", c.baseURL, c.owner, c.repo) -} - -func (c *Client) browseCommitsURL() string { - return fmt.Sprintf("https://github.com/%s/%s/commits/%s", c.owner, c.repo, c.branch) -} - func (c *Client) commitURL(hash string) string { return fmt.Sprintf("https://github.com/%s/%s/commit/%s", c.owner, c.repo, hash) } -func (c *Client) graphqlURL() string { - return fmt.Sprintf("%s/graphql", c.baseURL) +func (c *Client) compareURL(base, head string) string { + return fmt.Sprintf("https://github.com/%s/%s/compare/%s...%s", c.owner, c.repo, base, head) } // GetHeadCommitHash returns the current head commit hash for the configured repository and branch func (c *Client) GetHeadCommitHash(ctx context.Context) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.branchURL(), nil) + branch, resp, err := c.repos.GetBranch(ctx, c.owner, c.repo, c.branch, 0) if err != nil { - return "", fmt.Errorf("prepare http request: %w", err) - } - - resp, err := c.httpC.Do(req) - if err != nil { - return "", fmt.Errorf("get commit hash: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return "", fmt.Errorf("get branch %q: %w", c.branch, ErrNoRemoteBranch) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("get commit hash: http %d", resp.StatusCode) - } - - payload := struct { - Commit struct { - Sha string + if resp != nil && resp.StatusCode == http.StatusNotFound { + return "", fmt.Errorf("get branch %q: %w", c.branch, ErrNoRemoteBranch) } - }{} - - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", fmt.Errorf("decode commit hash response: %w", err) + return "", fmt.Errorf("get commit hash: %w", err) } - - return payload.Commit.Sha, nil + return branch.GetCommit().GetSHA(), nil } // CreateBranch attempts to create c.branch using headSha as the branch point func (c *Client) CreateBranch(ctx context.Context, headSha string) (string, error) { - log("Creating branch from commit %s\n", headSha) - - var input bytes.Buffer + logger.Printf("Creating branch from commit %s\n", headSha) - err := json.NewEncoder(&input).Encode(map[string]string{ - "ref": fmt.Sprintf("refs/heads/%s", c.branch), - "sha": headSha, - }) - if err != nil { - return "", err + ref := github.CreateRef{ + Ref: fmt.Sprintf("refs/heads/%s", c.branch), + SHA: headSha, } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.refsURL(), &input) + created, resp, err := c.git.CreateRef(ctx, c.owner, c.repo, ref) if err != nil { - return "", fmt.Errorf("prepare http request: %w", err) - } - - resp, err := c.httpC.Do(req) - if err != nil { - return "", fmt.Errorf("create branch request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusUnprocessableEntity { - // Parse the error response to distinguish between different failure modes - var errResp struct { - Message string `json:"message"` + if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity { + return "", fmt.Errorf("create branch: http 422 (does the branch point exist?)") } - if err := json.NewDecoder(resp.Body).Decode(&errResp); err == nil { - if strings.Contains(errResp.Message, "Reference already exists") { - return "", fmt.Errorf("create branch: branch %q already exists", c.branch) - } - if strings.Contains(errResp.Message, "Object does not exist") { - return "", fmt.Errorf("create branch: commit %q does not exist", headSha) - } - return "", fmt.Errorf("create branch: %s", errResp.Message) - } - return "", fmt.Errorf("create branch: http 422") - } - - if resp.StatusCode != http.StatusCreated { - return "", fmt.Errorf("create branch: http %d", resp.StatusCode) + return "", fmt.Errorf("create branch: %w", err) } - - payload := struct { - Commit struct { - Sha string - } `json:"object"` - }{} - - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", fmt.Errorf("decode create branch response: %w", err) - } - - return payload.Commit.Sha, nil + return created.GetObject().GetSHA(), nil } -// PushChanges takes a list of changes and a commit hash and produces commits using the GitHub GraphQL API. -// The commit hash is expected to be the current head of the remote branch, see [GetHeadCommitHash] -// for more. +// PushChanges creates commits for each change, then updates the branch ref once at the end. +// This is all-or-nothing: if any commit fails, the branch ref is not updated. // It returns the number of changes that were successfully pushed, the new head reference hash, and // any error encountered. func (c *Client) PushChanges(ctx context.Context, headCommit string, changes ...Change) (int, string, error) { var err error for i, change := range changes { - headCommit, err = c.PushChange(ctx, headCommit, change) + headCommit, err = c.CreateChange(ctx, headCommit, change) if err != nil { return i + 1, "", fmt.Errorf("push change %d: %w", i+i, err) } } - return len(changes), headCommit, nil -} - -// Splits a Change into added and deleted slices, taking into account existing files vs empty files -func (c *Client) splitChange(change Change) (added []fileAddition, deleted []fileDeletion) { - for path, content := range change.entries { - if content == nil { - deleted = append(deleted, fileDeletion{ - Path: path, - }) - } else { - added = append(added, fileAddition{ - Path: path, - Contents: content, - }) + if !c.dryrun { + _, _, err = c.git.UpdateRef(ctx, c.owner, c.repo, "refs/heads/"+c.branch, github.UpdateRef{ + SHA: headCommit, + Force: github.Ptr(c.force), + }) + if err != nil { + return len(changes), "", fmt.Errorf("update ref: %w", err) } } - return added, deleted + return len(changes), headCommit, nil } -// PushChange pushes a single change using the GraphQL API. -// It returns the hash of the pushed commit or an error. -func (c *Client) PushChange(ctx context.Context, headCommit string, change Change) (string, error) { - // Turn the change into a createCommitOnBranchInput - added, deleted := c.splitChange(change) - - input := createCommitOnBranchInput{ - Branch: commitInputBranch{ - Name: c.branch, - Target: fmt.Sprintf("%s/%s", c.owner, c.repo), - }, - ExpectedRef: headCommit, - Message: commitInputMessage{ - Headline: change.Headline(), - Body: change.Body(), - }, - Changes: commitInputChanges{ - Additions: added, - Deletions: deleted, - }, +// CreateChange creates a single commit from a change using the REST API. +// It does not update the branch ref — that is done by PushChanges after all commits succeed. +// It returns the hash of the created commit or an error. +func (c *Client) CreateChange(ctx context.Context, headCommit string, change Change) (string, error) { + shortHash := change.hash + if len(shortHash) > 8 { + shortHash = shortHash[:8] } + endGroup := logger.Group(fmt.Sprintf("Commit %s: %s", shortHash, change.Headline())) + defer endGroup() - query := wrapper{ - Query: ` - mutation ($input: CreateCommitOnBranchInput!) { - createCommitOnBranch(input: $input) { - commit { - oid - } - } - } - `, - Variables: map[string]any{"input": input}, + // Log commit details + if change.author != "" { + logger.Printf("Author: %s\n", change.author) } - - // Encode the query to JSON (so we can print it in case of an error) - queryJSON, err := json.Marshal(query) - if err != nil { - return "", fmt.Errorf("encode mutation: %w", err) + if body := change.Body(); body != "" { + logger.Printf("Body: %s\n", body) + } + logger.Printf("Changed files: %d\n", len(change.entries)) + for path, fe := range change.entries { + action := "MODIFY" + if fe.Content == nil { + action = "DELETE" + } + logger.Printf(" - %s: %s\n", action, path) } if c.dryrun { - log("Dry run enabled, not writing commit.\n") + logger.Notice("Dry run enabled, not writing commit") return strings.Repeat("0", len(change.hash)), nil } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphqlURL(), bytes.NewReader(queryJSON)) + // Get the parent commit's tree SHA + parentCommit, _, err := c.git.GetCommit(ctx, c.owner, c.repo, headCommit) if err != nil { - return "", fmt.Errorf("prepare mutation request: %w", err) + return "", fmt.Errorf("get parent commit: %w", err) } + baseTreeSHA := parentCommit.GetTree().GetSHA() - resp, err := c.httpC.Do(req) - defer resp.Body.Close() - if err != nil { - return "", err - } + // Build tree entries + var entries []*github.TreeEntry + for path, fe := range change.entries { + // Use the file's mode, defaulting to 100644 for regular files + mode := fe.Mode + if mode == "" { + mode = "100644" + } - payload := struct { - Data struct { - CreateCommitOnBranch struct { - Commit struct { - ObjectID string `json:"oid"` - } - } `json:"createCommitOnBranch"` + entry := &github.TreeEntry{ + Path: github.Ptr(path), + Mode: github.Ptr(mode), + Type: github.Ptr("blob"), } - Errors []struct { - Message string + if fe.Content == nil { + // Deletion: SHA must be empty string for go-github to omit it + } else { + // Create blob for additions/modifications + blob, _, err := c.git.CreateBlob(ctx, c.owner, c.repo, github.Blob{ + Content: github.Ptr(string(fe.Content)), + Encoding: github.Ptr("utf-8"), + }) + if err != nil { + return "", fmt.Errorf("create blob for %s: %w", path, err) + } + entry.SHA = blob.SHA } - }{} - - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", fmt.Errorf("decode mutation response body: %w", err) + entries = append(entries, entry) } - if len(payload.Errors) != 0 { - log("There were %d errors returned when creating the commit.\n", len(payload.Errors)) - for _, e := range payload.Errors { - log(" - %s\n", e.Message) - } - - return "", errors.New("graphql response") + // Create tree + tree, _, err := c.git.CreateTree(ctx, c.owner, c.repo, baseTreeSHA, entries) + if err != nil { + return "", fmt.Errorf("create tree: %w", err) } - oid := payload.Data.CreateCommitOnBranch.Commit.ObjectID - log("Pushed commit %s -> %s\n", change.hash, oid) - log(" Commit URL: %s\n", c.commitURL(oid)) + // Create commit (with signature verification retry) + message := change.Headline() + if body := change.Body(); body != "" { + message = message + "\n\n" + body + } - return oid, nil -} + commitInput := github.Commit{ + Message: github.Ptr(message), + Tree: &github.Tree{SHA: tree.SHA}, + Parents: []*github.Commit{{SHA: github.Ptr(headCommit)}}, + } -type wrapper struct { - Query string `json:"query"` - Variables map[string]any `json:"variables"` -} + commit, _, err := c.git.CreateCommit(ctx, c.owner, c.repo, commitInput, nil) + if err != nil { + return "", fmt.Errorf("create commit: %w", err) + } -type createCommitOnBranchInput struct { - Branch commitInputBranch `json:"branch"` - ExpectedRef string `json:"expectedHeadOid"` - Message commitInputMessage `json:"message"` - Changes commitInputChanges `json:"fileChanges"` -} + if c.signAttempts > 0 { + backoff := 1 * time.Second + for attempt := 1; attempt <= c.signAttempts; attempt++ { + if commit.GetVerification().GetVerified() { + break + } -type commitInputBranch struct { - Name string `json:"branchName"` - Target string `json:"repositoryNameWithOwner"` -} + if attempt == c.signAttempts { + reason := commit.GetVerification().GetReason() + return "", fmt.Errorf("commit %s was not signed after %d attempt(s) (reason: %s)", commit.GetSHA(), c.signAttempts, reason) + } -type commitInputMessage struct { - Headline string `json:"headline"` - Body string `json:"body"` -} + logger.Warningf("Commit %s not signed (attempt %d/%d, reason: %s), retrying in %s...", commit.GetSHA(), attempt, c.signAttempts, commit.GetVerification().GetReason(), backoff) + time.Sleep(backoff) + backoff *= 2 -type commitInputChanges struct { - Additions []fileAddition `json:"additions,omitempty"` - Deletions []fileDeletion `json:"deletions,omitempty"` -} + commit, _, err = c.git.CreateCommit(ctx, c.owner, c.repo, commitInput, nil) + if err != nil { + return "", fmt.Errorf("create commit (attempt %d): %w", attempt+1, err) + } + } + } -// fileAddition represents a file being added or modified. -// Contents is always included in the JSON output, even if empty. -type fileAddition struct { - Path string `json:"path"` - Contents []byte `json:"contents"` -} + commitSha := commit.GetSHA() + logger.Printf("Created: %s\n", c.commitURL(commitSha)) -// fileDeletion represents a file being deleted. -// It only contains the path; contents must not be included. -type fileDeletion struct { - Path string `json:"path"` + return commitSha, nil } diff --git a/github_test.go b/github_test.go index 668b540..e70735d 100644 --- a/github_test.go +++ b/github_test.go @@ -1,35 +1,724 @@ package main import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" "slices" + "strings" "testing" + + "github.com/google/go-github/v81/github" ) -func TestSplitChange(t *testing.T) { +func init() { + logger = NewLogger(io.Discard) +} + +// newFileEntry creates a FileEntry with default mode 100644 +func newFileEntry(content []byte) FileEntry { + return FileEntry{Content: content, Mode: "100644"} +} + +func TestBuildTreeEntries(t *testing.T) { change := Change{ - entries: map[string][]byte{ - "a-file": []byte("hello world"), - "b-empty": {}, - "deleted": nil, + entries: map[string]FileEntry{ + "a-file": newFileEntry([]byte("hello world")), + "b-empty": newFileEntry([]byte{}), + "deleted": {Content: nil}, // deletion }, } - added, deleted := (&Client{}).splitChange(change) + // Build tree entries the same way CreateChange does (without making API calls) + var addedPaths, deletedPaths []string + for path, fe := range change.entries { + if fe.Content == nil { + deletedPaths = append(deletedPaths, path) + } else { + addedPaths = append(addedPaths, path) + } + } - if len(added) != 2 { - t.Errorf("expected 2 added changes, got %d", len(added)) - } else { - additions := []string{added[0].Path, added[1].Path} - slices.Sort(additions) + slices.Sort(addedPaths) - if additions[0] != "a-file" || additions[1] != "b-empty" { - t.Errorf("expected 'a-file' and 'b-empty' to be added, but got %q", additions) - } + if len(addedPaths) != 2 { + t.Errorf("expected 2 added changes, got %d", len(addedPaths)) + } else if addedPaths[0] != "a-file" || addedPaths[1] != "b-empty" { + t.Errorf("expected 'a-file' and 'b-empty' to be added, but got %q", addedPaths) + } + + if len(deletedPaths) != 1 { + t.Errorf("expected 1 deleted change, got %d", len(deletedPaths)) + } else if deletedPaths[0] != "deleted" { + t.Errorf("expected deleted path to be 'deleted', got %s", deletedPaths[0]) } +} + +// newTestClient creates a Client configured to use the provided httptest server. +func newTestClient(t *testing.T, server *httptest.Server) *Client { + t.Helper() + ghClient := github.NewClient(nil) + ghClient.BaseURL, _ = ghClient.BaseURL.Parse(server.URL + "/") + + return &Client{ + repos: ghClient.Repositories, + git: ghClient.Git, + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + } +} + +func TestGetHeadCommitHash(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/test-owner/test-repo/branches/test-branch" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(github.Branch{ + Commit: &github.RepositoryCommit{ + SHA: github.Ptr("abc123def456"), + }, + }) + })) + defer server.Close() + + client := newTestClient(t, server) + sha, err := client.GetHeadCommitHash(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sha != "abc123def456" { + t.Errorf("expected sha 'abc123def456', got %q", sha) + } + }) + + t.Run("branch not found", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Branch not found", + }) + })) + defer server.Close() + + client := newTestClient(t, server) + _, err := client.GetHeadCommitHash(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), ErrNoRemoteBranch.Error()) { + t.Errorf("expected error to contain %q, got %q", ErrNoRemoteBranch.Error(), err.Error()) + } + }) + + t.Run("server error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + client := newTestClient(t, server) + _, err := client.GetHeadCommitHash(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestCreateBranch(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/repos/test-owner/test-repo/git/refs" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + var req github.CreateRef + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("failed to decode request: %v", err) + } + if req.Ref != "refs/heads/test-branch" { + t.Errorf("expected ref 'refs/heads/test-branch', got %q", req.Ref) + } + if req.SHA != "parent-sha-123" { + t.Errorf("expected sha 'parent-sha-123', got %q", req.SHA) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Reference{ + Object: &github.GitObject{ + SHA: github.Ptr("parent-sha-123"), + }, + }) + })) + defer server.Close() + + client := newTestClient(t, server) + sha, err := client.CreateBranch(context.Background(), "parent-sha-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sha != "parent-sha-123" { + t.Errorf("expected sha 'parent-sha-123', got %q", sha) + } + }) + + t.Run("branch point does not exist", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Reference does not exist", + }) + })) + defer server.Close() + + client := newTestClient(t, server) + _, err := client.CreateBranch(context.Background(), "nonexistent-sha") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "422") { + t.Errorf("expected error to mention 422, got %q", err.Error()) + } + }) +} + +func TestCreateChange(t *testing.T) { + t.Run("dry run returns zero hash", func(t *testing.T) { + client := &Client{ + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + dryrun: true, + } + + change := Change{ + hash: "abc123", + message: "Test commit", + entries: map[string]FileEntry{ + "file.txt": newFileEntry([]byte("content")), + }, + } + + sha, err := client.CreateChange(context.Background(), "head-sha", change) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should return zeros with same length as input hash + if sha != "000000" { + t.Errorf("expected '000000', got %q", sha) + } + }) + + t.Run("successful push with file addition", func(t *testing.T) { + var ( + getCommitCalled bool + createBlobCalled bool + createTreeCalled bool + commitCalled bool + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + getCommitCalled = true + json.NewEncoder(w).Encode(github.Commit{ + SHA: github.Ptr("parent-sha"), + Tree: &github.Tree{ + SHA: github.Ptr("parent-tree-sha"), + }, + }) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + createBlobCalled = true + var blob github.Blob + json.NewDecoder(r.Body).Decode(&blob) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{ + SHA: github.Ptr("blob-sha-123"), + }) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/trees": + createTreeCalled = true + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{ + SHA: github.Ptr("new-tree-sha"), + }) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + commitCalled = true + var commit github.Commit + json.NewDecoder(r.Body).Decode(&commit) + if commit.GetMessage() != "Test commit" { + t.Errorf("unexpected commit message: %s", commit.GetMessage()) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Commit{ + SHA: github.Ptr("new-commit-sha"), + }) + + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local-hash", + message: "Test commit", + entries: map[string]FileEntry{ + "file.txt": newFileEntry([]byte("content")), + }, + } + + sha, err := client.CreateChange(context.Background(), "parent-sha", change) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sha != "new-commit-sha" { + t.Errorf("expected 'new-commit-sha', got %q", sha) + } + + if !getCommitCalled { + t.Error("GetCommit was not called") + } + if !createBlobCalled { + t.Error("CreateBlob was not called") + } + if !createTreeCalled { + t.Error("CreateTree was not called") + } + if !commitCalled { + t.Error("CreateCommit was not called") + } + }) + + t.Run("successful push with file deletion", func(t *testing.T) { + var treeEntries []*github.TreeEntry + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + SHA: github.Ptr("parent-sha"), + Tree: &github.Tree{ + SHA: github.Ptr("parent-tree-sha"), + }, + }) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/trees": + var req struct { + BaseTree string `json:"base_tree"` + Tree []*github.TreeEntry `json:"tree"` + } + json.NewDecoder(r.Body).Decode(&req) + treeEntries = req.Tree + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{ + SHA: github.Ptr("new-tree-sha"), + }) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Commit{ + SHA: github.Ptr("new-commit-sha"), + }) + + case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/refs/"): + json.NewEncoder(w).Encode(github.Reference{ + Object: &github.GitObject{ + SHA: github.Ptr("new-commit-sha"), + }, + }) + + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local-hash", + message: "Delete file", + entries: map[string]FileEntry{ + "deleted-file.txt": {Content: nil}, // deletion + }, + } + + _, err := client.CreateChange(context.Background(), "parent-sha", change) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify that the tree entry for deletion has no SHA (which signals deletion) + if len(treeEntries) != 1 { + t.Fatalf("expected 1 tree entry, got %d", len(treeEntries)) + } + if treeEntries[0].GetPath() != "deleted-file.txt" { + t.Errorf("expected path 'deleted-file.txt', got %q", treeEntries[0].GetPath()) + } + // For deletions, SHA should be nil/empty + if treeEntries[0].SHA != nil && *treeEntries[0].SHA != "" { + t.Errorf("expected nil/empty SHA for deletion, got %q", *treeEntries[0].SHA) + } + }) + + t.Run("commit with body", func(t *testing.T) { + var receivedMessage string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/trees": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{SHA: github.Ptr("tree-sha")}) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + var commit github.Commit + json.NewDecoder(r.Body).Decode(&commit) + receivedMessage = commit.GetMessage() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Commit{SHA: github.Ptr("commit-sha")}) + + case r.Method == http.MethodPatch: + json.NewEncoder(w).Encode(github.Reference{ + Object: &github.GitObject{SHA: github.Ptr("commit-sha")}, + }) + } + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local", + message: "Headline\n\nThis is the body\nwith multiple lines", + entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, + } + + _, err := client.CreateChange(context.Background(), "parent", change) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(receivedMessage, "Headline") { + t.Errorf("message should contain headline, got %q", receivedMessage) + } + if !strings.Contains(receivedMessage, "This is the body") { + t.Errorf("message should contain body, got %q", receivedMessage) + } + }) + + t.Run("get parent commit fails", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local", + message: "Test", + entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, + } + + _, err := client.CreateChange(context.Background(), "nonexistent", change) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "get parent commit") { + t.Errorf("expected 'get parent commit' error, got %q", err.Error()) + } + }) + + t.Run("create blob fails", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/") { + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + return + } + if r.URL.Path == "/repos/test-owner/test-repo/git/blobs" { + w.WriteHeader(http.StatusInternalServerError) + return + } + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local", + message: "Test", + entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, + } + + _, err := client.CreateChange(context.Background(), "parent", change) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "create blob") { + t.Errorf("expected 'create blob' error, got %q", err.Error()) + } + }) + + t.Run("create tree fails", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + case r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) + case r.URL.Path == "/repos/test-owner/test-repo/git/trees": + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local", + message: "Test", + entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, + } + + _, err := client.CreateChange(context.Background(), "parent", change) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "create tree") { + t.Errorf("expected 'create tree' error, got %q", err.Error()) + } + }) - if len(deleted) != 1 { - t.Errorf("expected 1 deleted change, got %d", len(deleted)) - } else if deleted[0].Path != "deleted" { - t.Errorf("expected deleted[0].Path to be 'deleted', got %s", deleted[0].Path) + t.Run("create commit fails", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + case r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) + case r.URL.Path == "/repos/test-owner/test-repo/git/trees": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{SHA: github.Ptr("tree-sha")}) + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local", + message: "Test", + entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, + } + + _, err := client.CreateChange(context.Background(), "parent", change) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "create commit") { + t.Errorf("expected 'create commit' error, got %q", err.Error()) + } + }) + +} + +func TestPushChanges(t *testing.T) { + t.Run("multiple changes", func(t *testing.T) { + commitCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + case r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) + case r.URL.Path == "/repos/test-owner/test-repo/git/trees": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{SHA: github.Ptr("tree-sha")}) + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + commitCount++ + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Commit{SHA: github.Ptr("commit-sha-" + string(rune('0'+commitCount)))}) + case r.Method == http.MethodPatch: + json.NewEncoder(w).Encode(github.Reference{ + Object: &github.GitObject{SHA: github.Ptr("final-sha")}, + }) + } + })) + defer server.Close() + + client := newTestClient(t, server) + changes := []Change{ + {hash: "h1", message: "First", entries: map[string]FileEntry{"a.txt": newFileEntry([]byte("a"))}}, + {hash: "h2", message: "Second", entries: map[string]FileEntry{"b.txt": newFileEntry([]byte("b"))}}, + {hash: "h3", message: "Third", entries: map[string]FileEntry{"c.txt": newFileEntry([]byte("c"))}}, + } + + count, sha, err := client.PushChanges(context.Background(), "initial", changes...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count != 3 { + t.Errorf("expected count 3, got %d", count) + } + if sha == "" { + t.Error("expected non-empty sha") + } + if commitCount != 3 { + t.Errorf("expected 3 commits to be created, got %d", commitCount) + } + }) + + t.Run("failure on second change", func(t *testing.T) { + commitCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + case r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) + case r.URL.Path == "/repos/test-owner/test-repo/git/trees": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{SHA: github.Ptr("tree-sha")}) + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + commitCount++ + if commitCount == 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Commit{SHA: github.Ptr("commit-sha")}) + case r.Method == http.MethodPatch: + json.NewEncoder(w).Encode(github.Reference{ + Object: &github.GitObject{SHA: github.Ptr("sha")}, + }) + } + })) + defer server.Close() + + client := newTestClient(t, server) + changes := []Change{ + {hash: "h1", message: "First", entries: map[string]FileEntry{"a.txt": newFileEntry([]byte("a"))}}, + {hash: "h2", message: "Second", entries: map[string]FileEntry{"b.txt": newFileEntry([]byte("b"))}}, + {hash: "h3", message: "Third", entries: map[string]FileEntry{"c.txt": newFileEntry([]byte("c"))}}, + } + + count, _, err := client.PushChanges(context.Background(), "initial", changes...) + if err == nil { + t.Fatal("expected error, got nil") + } + if count != 2 { + t.Errorf("expected count 2 (failed on second), got %d", count) + } + }) + + t.Run("update ref fails", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + case r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) + case r.URL.Path == "/repos/test-owner/test-repo/git/trees": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{SHA: github.Ptr("tree-sha")}) + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Commit{SHA: github.Ptr("commit-sha")}) + case r.Method == http.MethodPatch: + w.WriteHeader(http.StatusConflict) + } + })) + defer server.Close() + + client := newTestClient(t, server) + changes := []Change{ + {hash: "h1", message: "First", entries: map[string]FileEntry{"a.txt": newFileEntry([]byte("a"))}}, + } + + _, _, err := client.PushChanges(context.Background(), "parent", changes...) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "update ref") { + t.Errorf("expected 'update ref' error, got %q", err.Error()) + } + }) +} + +func TestURLHelpers(t *testing.T) { + client := &Client{ + owner: "myorg", + repo: "myrepo", + branch: "feature-branch", } + + t.Run("compareURL", func(t *testing.T) { + url := client.compareURL("abc123", "def456") + expected := "https://github.com/myorg/myrepo/compare/abc123...def456" + if url != expected { + t.Errorf("expected %q, got %q", expected, url) + } + }) + + t.Run("commitURL", func(t *testing.T) { + url := client.commitURL("abc123") + expected := "https://github.com/myorg/myrepo/commit/abc123" + if url != expected { + t.Errorf("expected %q, got %q", expected, url) + } + }) } diff --git a/go.mod b/go.mod index 884ea09..8f8725d 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,8 @@ go 1.24.10 require ( github.com/alecthomas/kong v1.11.0 + github.com/google/go-github/v81 v81.0.0 golang.org/x/oauth2 v0.30.0 ) + +require github.com/google/go-querystring v1.1.0 // indirect diff --git a/go.sum b/go.sum index 211edf3..d9c1302 100644 --- a/go.sum +++ b/go.sum @@ -4,7 +4,15 @@ github.com/alecthomas/kong v1.11.0 h1:y++1gI7jf8O7G7l4LZo5ASFhrhJvzc+WgF/arranEm github.com/alecthomas/kong v1.11.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v81 v81.0.0 h1:hTLugQRxSLD1Yei18fk4A5eYjOGLUBKAl/VCqOfFkZc= +github.com/google/go-github/v81 v81.0.0/go.mod h1:upyjaybucIbBIuxgJS7YLOZGziyvvJ92WX6WEBNE3sM= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..87607e6 --- /dev/null +++ b/logger.go @@ -0,0 +1,125 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "os" +) + +// Logger provides structured logging with GitHub Actions workflow command support. +type Logger struct { + w io.Writer + actions bool + githubOutput string +} + +// NewLogger creates a logger that writes to w. If running in GitHub Actions +// (detected via GITHUB_ACTIONS env var), it writes to stdout instead (required +// for workflow commands) and emits workflow commands for grouping and annotations. +func NewLogger(w io.Writer) *Logger { + actions := os.Getenv("GITHUB_ACTIONS") == "true" + githubOutput := os.Getenv("GITHUB_OUTPUT") + if actions { + w = os.Stdout + } + return &Logger{ + w: w, + actions: actions, + githubOutput: githubOutput, + } +} + +// Printf writes a formatted message to the log. +func (l *Logger) Printf(f string, args ...any) { + fmt.Fprintf(l.w, f, args...) +} + +// Group starts a collapsible group in GitHub Actions logs. +// Returns a function that must be called to end the group. +// Usage: +// +// end := logger.Group("Processing files") +// defer end() +func (l *Logger) Group(title string) func() { + if l.actions { + fmt.Fprintf(l.w, "::group::%s\n", title) + return func() { fmt.Fprintln(l.w, "::endgroup::") } + } + fmt.Fprintf(l.w, "%s\n", title) + return func() {} +} + +// Notice emits an informational annotation in GitHub Actions, +// or a regular log message otherwise. +func (l *Logger) Notice(msg string) { + if l.actions { + fmt.Fprintf(l.w, "::notice::%s\n", msg) + } else { + fmt.Fprintf(l.w, "%s\n", msg) + } +} + +// Noticef emits a formatted notice. +func (l *Logger) Noticef(f string, args ...any) { + l.Notice(fmt.Sprintf(f, args...)) +} + +// Warning emits a warning annotation in GitHub Actions, +// or a prefixed log message otherwise. +func (l *Logger) Warning(msg string) { + if l.actions { + fmt.Fprintf(l.w, "::warning::%s\n", msg) + } else { + fmt.Fprintf(l.w, "warning: %s\n", msg) + } +} + +// Warningf emits a formatted warning. +func (l *Logger) Warningf(f string, args ...any) { + l.Warning(fmt.Sprintf(f, args...)) +} + +// Error emits an error annotation in GitHub Actions, +// or a prefixed log message otherwise. +func (l *Logger) Error(msg string) { + if l.actions { + fmt.Fprintf(l.w, "::error::%s\n", msg) + } else { + fmt.Fprintf(l.w, "error: %s\n", msg) + } +} + +// Errorf emits a formatted error. +func (l *Logger) Errorf(f string, args ...any) { + l.Error(fmt.Sprintf(f, args...)) +} + +// Output writes a value that should be captured by the caller. +// In GitHub Actions, this writes to GITHUB_OUTPUT file. Otherwise, it prints to stdout. +// The name parameter is used as the output variable name in Actions. +func (l *Logger) Output(name, value string) error { + if l.githubOutput != "" { + f, err := os.OpenFile(l.githubOutput, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644) + if err != nil { + return fmt.Errorf("open GITHUB_OUTPUT: %w", err) + } + defer f.Close() + + // Use heredoc syntax for multiline-safe output + delim := randomDelimiter() + _, err = fmt.Fprintf(f, "%s<<%s\n%s\n%s\n", name, delim, value, delim) + return err + } + + // Outside Actions, just print to stdout for capture + fmt.Println(value) + return nil +} + +func randomDelimiter() string { + b := make([]byte, 16) + rand.Read(b) + return "delim_" + hex.EncodeToString(b) +} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..913a8e3 --- /dev/null +++ b/logger_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoggerOutput(t *testing.T) { + t.Run("writes to stdout when no GITHUB_OUTPUT", func(t *testing.T) { + var buf bytes.Buffer + l := &Logger{w: &buf, actions: false, githubOutput: ""} + + err := l.Output("test_name", "test_value") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should print to stdout (which we can't capture here, but Output returns nil) + // The actual stdout write happens in the real Output method + }) + + t.Run("writes to GITHUB_OUTPUT file when set", func(t *testing.T) { + tmpDir := t.TempDir() + outputFile := filepath.Join(tmpDir, "github_output") + + var buf bytes.Buffer + l := &Logger{w: &buf, actions: true, githubOutput: outputFile} + + err := l.Output("pushed_ref", "abc123def456") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + + // Should contain the variable name and value in heredoc format + if !strings.Contains(string(content), "pushed_ref<