Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
jeffreyc committed Apr 11, 2023
1 parent 4ea0466 commit 7a0e50d
Show file tree
Hide file tree
Showing 9 changed files with 391 additions and 0 deletions.
98 changes: 98 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
name: Test Copy Commit

on:
pull_request:
push:
branches:
- master

concurrency:
group: ${{ github.ref }}
cancel-in-progress: true

jobs:
copy-commit:
runs-on: ubuntu-latest
steps:
- name: Checkout Copy Commit Action
uses: actions/checkout@v3
- name: Configure Git
run: |
git config --global user.email "user@host.domain"
git config --global user.name "Test User"
- name: Create Commit for No-Args Test
run: |
echo "no-args-1" > ${GITHUB_SHA}-no-args-1
echo "no-args-2" > ${GITHUB_SHA}-no-args-2
git add ${GITHUB_SHA}-no-args-1 ${GITHUB_SHA}-no-args-2
git commit -m "test no-args"
- name: Test Copy Commit with No Args
uses: ./
env:
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
destination: 'jeffreyc/copy-commit-to-another-repo-test'
- name: Create Commit for Include Test
run: |
echo "include-1" > ${GITHUB_SHA}-include-1
echo "include-2" > ${GITHUB_SHA}-include-2
echo "include-3" > ${GITHUB_SHA}-include-3
git add ${GITHUB_SHA}-include-1 ${GITHUB_SHA}-include-2 ${GITHUB_SHA}-include-3
git commit -m "test include"
- name: Test Copy Commit with Include
uses: ./
env:
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
destination: 'jeffreyc/copy-commit-to-another-repo-test'
include: "${{ github.sha }}-include-1,${{ github.sha }}-include-2"
- name: Create Commit for Exclude Test
run: |
echo "exclude-1" > ${GITHUB_SHA}-exclude-1
echo "exclude-2" > ${GITHUB_SHA}-exclude-2
echo "exclude-3" > ${GITHUB_SHA}-exclude-3
git add ${GITHUB_SHA}-exclude-1 ${GITHUB_SHA}-exclude-2 ${GITHUB_SHA}-exclude-3
git commit -m "test exclude"
- name: Test Copy Commit with Exclude
uses: ./
env:
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
destination: 'jeffreyc/copy-commit-to-another-repo-test'
exclude: "${{ github.sha }}-exclude-1,${{ github.sha }}-exclude-3"
- name: Create Commit for Include-and-Exclude Test
run: |
echo "include-and-exclude-1" > ${GITHUB_SHA}-include-and-exclude-1
echo "include-and-exclude-2" > ${GITHUB_SHA}-include-and-exclude-2
echo "include-and-exclude-3" > ${GITHUB_SHA}-include-and-exclude-3
git add ${GITHUB_SHA}-include-and-exclude-1 ${GITHUB_SHA}-include-and-exclude-2 ${GITHUB_SHA}-include-and-exclude-3
git commit -m "test include and exclude"
- name: Test Copy Commit with Include and Exclude
uses: ./
env:
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
destination: 'jeffreyc/copy-commit-to-another-repo-test'
exclude: "${{ github.sha }}-include-and-exclude-1,${{ github.sha }}-include-and-exclude-3"
include: "${{ github.sha }}-include-and-exclude-1,${{ github.sha }}-include-and-exclude-2"
# - name: Create Commit for Branch Test
# run: |
# echo "branch-1" > ${GITHUB_SHA}-branch-1
# git add ${GITHUB_SHA}-branch-1
# git commit -m "test branch"
# - name: Test Copy Commit with Branch
# uses: ./
# env:
# PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
# with:
# destination: 'jeffreyc/copy-commit-to-another-repo-test'
# branch: "TODO"
- name: Checkout Test Destination
uses: actions/checkout@v3
with:
fetch-depth: 5
path: test
repository: 'jeffreyc/copy-commit-to-another-repo-test'
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- name: Verify Commits
run: python test_action.py
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.idea
__pycache__
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [1.0.0] - 2023-04-11

* Initial Release

[unreleased]: https://github.com/jeffreyc/copy-commit-to-another-repo/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/jeffreyc/copy-commit-to-another-repo/releases/tag/v1.0.0
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM python:alpine

RUN apk update && \
apk upgrade && \
apk add git

ADD copy_commit.py /copy_commit.py

ENTRYPOINT ["python", "/copy_commit.py"]
9 changes: 9 additions & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Copyright 2023 Jeff Cousens

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# copy-commit-to-another-repo

`copy-commit-to-another-repo` is a GitHub Action that copies commits from the current repository to another repository.
The intent is to enable keeping two isolated repositories in sync; _e.g._, you have `work` and `home` repositories for your dotfiles, and you want to be able to update either, keeping shared files in sync, including commit messages, while not syncing everything.

## Configuration

### Environment

- `PERSONAL_ACCESS_TOKEN` - this needs to be set under `Settings` -> `Secrets and variables` -> `Actions` -> `New repository secret` on the source repository.
Create a token under `Settings` -> `Developer settings` -> `Personal access tokens` -> `Tokens (classic)` or `Fine-grained tokens`.
The token will need `repo` permissions (classic) or `Repository permissions` -> `Contents` permissions (fine-grained).

### Inputs

- `destination` - the repository to copy commits to.
- `branch` [optional] - the branch to copy commits to. If unspecified, the default branch for `destination` will be used.
- `include` [optional] - a comma-separated list of [regular expressions](https://docs.python.org/3/howto/regex.html) to match against; e.g., `hello,world?`.
If specified, only changes to files that match a pattern in `include` will be copied.
Both `include` and `exclude` may be specified.
If neither `include` nor `exclude` are specified, all changes will be copied.
- `exclude` [optional] - a comma-separated list of [regular expressions](https://docs.python.org/3/howto/regex.html) to match against; e.g., `hello,world?`.
If specified, all changes will be copied except changes to files that match a pattern in `exclude`.
Both `include` and `exclude` may be specified.
If neither `include` nor `exclude` are specified, all changes will be copied.

## Usage

```yaml
name: Copy Commit

on: push

jobs:
copy-commit:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 2
- name: Copy Commit
uses: jeffreyc/copy-commit-to-another-repo@v1.0.0
env:
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
include: 'hello,world?'
exclude: '"world,"'
destination: 'jeffreyc/hello-copy'
branch: 'staging'
```
_n.b._, you must specify `fetch-depth: 2` for the `Checkout` action, else git will be unable to determine what has changed.

## License

`copy-commit-to-another-repo` is released under the [BSD 3-Clause License](LICENSE.md).
27 changes: 27 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: 'Copy commits to another repo'
author: 'Jeff Cousens'
description: 'Copies all or part of a commit to another repository'
inputs:
include:
description: 'Comma-separated list of regular expressions to include'
required: false
exclude:
description: 'Comma-separated list of regular expressions to exclude'
required: false
destination:
description: 'Destination repository'
required: true
branch:
description: "Branch to push changes to in the destination repository"
required: false
runs:
using: 'docker'
image: 'Dockerfile'
args:
- ${{ inputs.include }}
- ${{ inputs.exclude }}
- ${{ inputs.destination }}
- ${{ inputs.branch }}
branding:
icon: 'copy'
color: 'white'
125 changes: 125 additions & 0 deletions copy_commit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/env python

import csv
import io
import logging
import os
import re
import subprocess
import sys
import tempfile
from typing import Optional, Pattern


class CopyCommit:
def __init__(self, log_level: int = logging.DEBUG):
self.logger = self.get_logger(log_level)
self.cwd = os.environ["GITHUB_WORKSPACE"]

@staticmethod
def get_logger(log_level: int = logging.DEBUG) -> logging.Logger:
logger = logging.getLogger("copy-commit-to-another-repo")
handler = logging.StreamHandler()
logger.addHandler(handler)
logger.setLevel(log_level)
return logger

@staticmethod
def match(item: str, patterns: list[Pattern[str]]) -> bool:
if [i for i in [p.match(item) for p in patterns] if i is not None]:
return True
return False

def parse_csv(self, to_parse: str) -> list[str]:
f = io.StringIO(to_parse)
reader = csv.reader(f, delimiter=",")
return (list(reader) or [[]])[0]

def require(self, var: str, name: str) -> str:
if not os.environ[var]:
self.logger.critical(f"{name} must be specified")
sys.exit(1)
return os.environ[var].strip()

def run(self, cmd: str, cwd: Optional[str] = None) -> str:
self.logger.info(f"Running `{cmd}` in `{cwd or self.cwd}`")
try:
ret = subprocess.check_output(
cmd, stderr=subprocess.STDOUT, shell=True, cwd=cwd
).decode("ascii")
except subprocess.CalledProcessError as e:
self.logger.critical(
"Exception on process, rc=", e.returncode, "output=", e.output
)
raise
self.logger.info(ret)
return ret

def main(self) -> None:
token = self.require("PERSONAL_ACCESS_TOKEN", "PERSONAL_ACCESS_TOKEN")
destination = self.require("INPUT_DESTINATION", "destination")

self.run(f"git config --global --add safe.directory {self.cwd}")

username = self.run("git log --pretty=format:%an -1")
self.run(f'git config --global user.name "{username}"')

email = self.run("git log --pretty=format:%ae -1")
self.run(f'git config --global user.email "{email}"')

excluded = [
re.compile(pattern)
for pattern in self.parse_csv(os.environ["INPUT_EXCLUDE"])
if pattern
]
self.logger.debug(f"excluded: {excluded}")
included = [
re.compile(pattern)
for pattern in self.parse_csv(os.environ["INPUT_INCLUDE"])
if pattern
]
self.logger.debug(f"included: {included}")

with tempfile.TemporaryDirectory() as tmpdir:
if os.environ["INPUT_BRANCH"]:
branch = os.environ["INPUT_BRANCH"]
self.run(
f'git clone --single-branch --branch {branch} "https://x-access-token:{token}@github.com/{destination}.git" "{tmpdir}"'
)
else:
self.run(
f'git clone --single-branch "https://x-access-token:{token}@github.com/{destination}.git" "{tmpdir}"'
)

modified = self.run(
f"git diff-tree --no-commit-id --name-only HEAD -r"
).split()

self.logger.debug(f"modified: {modified}")

keep = []
for item in modified:
if (not included or self.match(item, included)) and (
not excluded or not self.match(item, excluded)
):
keep.append(item)

self.logger.debug(f"keep: {keep}")

if keep:
keep = " ".join(keep)
self.run(
f"git --git-dir={self.cwd}/.git format-patch -k -1 --stdout HEAD -- {keep} | git am -3 -k",
tmpdir,
)
self.run("git log -2", tmpdir)
self.run("git push -u origin", tmpdir)
else:
self.logger.info(
"All files excluded or no files included, nothing to apply."
)


if __name__ == "__main__":
cc = CopyCommit()
cc.main()
49 changes: 49 additions & 0 deletions test_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env python

import logging
import os
import unittest

import copy_commit


class TestAction(unittest.TestCase):
def setUp(self):
self.cc = copy_commit.CopyCommit(logging.CRITICAL)
self.repo_path = os.path.join(os.environ["GITHUB_WORKSPACE"], "test")
self.sha = os.environ["GITHUB_SHA"]

def get_changed_files(self, rev):
return self.cc.run(
f"git diff-tree --no-commit-id --name-only {rev} -r", self.repo_path
).split()

def test_branch(self):
# HEAD..HEAD~1 on <branch>
pass

def test_exclude(self):
changed = self.get_changed_files("HEAD~1..HEAD~2")
self.assertIn(f"{self.sha}-exclude-2", changed)

def test_include(self):
changed = self.get_changed_files("HEAD~2..HEAD~3")
self.assertIn(f"{self.sha}-include-1", changed)
self.assertIn(f"{self.sha}-include-2", changed)

def test_include_and_exclude(self):
changed = self.get_changed_files("HEAD..HEAD~1")
self.assertIn(f"{self.sha}-include-and-exclude-2", changed)

def test_no_args(self):
changed = self.get_changed_files("HEAD~3..HEAD~4")
self.assertIn(f"{self.sha}-no-args-1", changed)
self.assertIn(f"{self.sha}-no-args-2", changed)
author = self.cc.run("git log --format='%an <%ae>' -1 HEAD~3", self.repo_path).strip()
self.assertEqual("Test User <user@host.domain>", author)
raw_body = self.cc.run("git log --format='%B' -1 HEAD~3", self.repo_path).strip()
self.assertEqual("test no-args", raw_body)


if __name__ == "__main__":
unittest.main()

0 comments on commit 7a0e50d

Please sign in to comment.