mirror of
https://ghfast.top/https://github.com/bradvin/wp-git-sync.git
synced 2026-05-12 23:22:32 +08:00
593 lines
18 KiB
PHP
593 lines
18 KiB
PHP
<?php
|
|
/**
|
|
* GitHub repo operations.
|
|
*
|
|
* @package WPGitSync
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* High-level GitHub operations (branch + commit creation).
|
|
*
|
|
* Side effects:
|
|
* - Calls GitHub REST API.
|
|
*/
|
|
final class WPGS_GitHub_Provider {
|
|
/**
|
|
* Canonical SHA-1 for an empty Git tree object.
|
|
*/
|
|
private const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
|
|
/**
|
|
* Fetch a file's raw contents from a branch using the Contents API.
|
|
*
|
|
* Side effects:
|
|
* - Calls GitHub REST API.
|
|
*
|
|
* @param string $branch Branch.
|
|
* @param string $path Repo-relative file path.
|
|
* @return string Raw file contents.
|
|
* @throws RuntimeException When the file cannot be fetched/decoded.
|
|
*/
|
|
public function get_file_contents( string $branch, string $path ): string {
|
|
$path = ltrim( $path, '/' );
|
|
$data = $this->client->request( 'GET', $this->api( '/contents/' . str_replace( '%2F', '/', rawurlencode( $path ) ) . '?ref=' . rawurlencode( $branch ) ) );
|
|
$decoded = $this->decode_base64_payload( $data );
|
|
if ( null !== $decoded ) {
|
|
return $decoded;
|
|
}
|
|
|
|
// Fallback: some Contents API responses omit/limit inline content. In that
|
|
// case, decode via the Git Blob API using the file SHA.
|
|
$blob_sha = isset( $data['sha'] ) ? trim( (string) $data['sha'] ) : '';
|
|
if ( '' !== $blob_sha ) {
|
|
$blob = $this->client->request( 'GET', $this->api( '/git/blobs/' . rawurlencode( $blob_sha ) ) );
|
|
$decoded_blob = $this->decode_base64_payload( $blob );
|
|
if ( null !== $decoded_blob ) {
|
|
return $decoded_blob;
|
|
}
|
|
|
|
throw new RuntimeException(
|
|
sprintf(
|
|
/* translators: 1: File path, 2: Git blob SHA. */
|
|
esc_html__( 'Unable to decode blob content from GitHub for "%1$s" (sha: %2$s).', 'wp-git-sync' ),
|
|
esc_html( $path ),
|
|
esc_html( $blob_sha )
|
|
)
|
|
);
|
|
}
|
|
|
|
$encoding = isset( $data['encoding'] ) ? (string) $data['encoding'] : 'unknown';
|
|
throw new RuntimeException(
|
|
sprintf(
|
|
/* translators: 1: File path, 2: Reported encoding. */
|
|
esc_html__( 'Unable to decode file from GitHub for "%1$s" (encoding: %2$s).', 'wp-git-sync' ),
|
|
esc_html( $path ),
|
|
esc_html( $encoding )
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Decode a GitHub base64 payload into raw file contents.
|
|
*
|
|
* @param array<string,mixed> $data API payload.
|
|
* @return string|null Raw content, or null when unavailable/invalid.
|
|
*/
|
|
private function decode_base64_payload( array $data ): ?string {
|
|
$encoding = isset( $data['encoding'] ) ? strtolower( (string) $data['encoding'] ) : '';
|
|
if ( 'base64' !== $encoding ) {
|
|
return null;
|
|
}
|
|
|
|
$has_content = array_key_exists( 'content', $data );
|
|
$content = $has_content ? (string) $data['content'] : '';
|
|
if ( ! $has_content ) {
|
|
$size = isset( $data['size'] ) ? (int) $data['size'] : -1;
|
|
return 0 === $size ? '' : null;
|
|
}
|
|
|
|
$decoded = base64_decode( str_replace( [ "\n", "\r" ], '', $content ), true );
|
|
if ( false === $decoded ) {
|
|
return null;
|
|
}
|
|
|
|
return (string) $decoded;
|
|
}
|
|
/**
|
|
* API client.
|
|
*/
|
|
private WPGS_GitHub_Client $client;
|
|
|
|
/**
|
|
* Repo identifier in the form "owner/repo".
|
|
*/
|
|
private string $repo;
|
|
|
|
/**
|
|
* @param WPGS_GitHub_Client $client HTTP client.
|
|
* @param string $repo Repo in the form owner/repo.
|
|
* @throws InvalidArgumentException If repo is invalid.
|
|
*/
|
|
public function __construct( WPGS_GitHub_Client $client, string $repo ) {
|
|
$this->client = $client;
|
|
$this->repo = trim( $repo );
|
|
if ( '' === $this->repo || false === strpos( $this->repo, '/' ) ) {
|
|
throw new InvalidArgumentException( esc_html__( 'Repo must be in the form owner/repo.', 'wp-git-sync' ) );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure a branch exists.
|
|
*
|
|
* Side effects:
|
|
* - May create the branch from the default branch.
|
|
*
|
|
* @param string $branch Branch name.
|
|
* @return void
|
|
*/
|
|
public function ensure_branch( string $branch ): void {
|
|
$branch = trim( $branch );
|
|
if ( '' === $branch ) {
|
|
throw new InvalidArgumentException( esc_html__( 'Branch is required.', 'wp-git-sync' ) );
|
|
}
|
|
|
|
try {
|
|
$this->get_ref_sha( $branch );
|
|
return;
|
|
} catch ( Throwable $e ) {
|
|
// Empty repos have no refs yet; initial commit path will create one.
|
|
if ( $this->is_empty_repo_error( $e ) ) {
|
|
return;
|
|
}
|
|
// Branch missing; create it below.
|
|
}
|
|
|
|
$repo_info = $this->client->request( 'GET', $this->api( '' ) );
|
|
$default = isset( $repo_info['default_branch'] ) ? (string) $repo_info['default_branch'] : 'main';
|
|
try {
|
|
$default_head = $this->get_ref_sha( $default );
|
|
} catch ( Throwable $e ) {
|
|
// Empty repos have no default branch head yet.
|
|
if ( $this->is_empty_repo_error( $e ) ) {
|
|
return;
|
|
}
|
|
throw $e;
|
|
}
|
|
|
|
$this->create_ref( $branch, $default_head );
|
|
}
|
|
|
|
/**
|
|
* Get the current commit SHA for a branch.
|
|
*
|
|
* @param string $branch Branch name.
|
|
* @return string Commit SHA.
|
|
*/
|
|
public function get_ref_sha( string $branch ): string {
|
|
$data = $this->client->request( 'GET', $this->api( '/git/refs/heads/' . rawurlencode( $branch ) ) );
|
|
$sha = isset( $data['object']['sha'] ) ? (string) $data['object']['sha'] : '';
|
|
if ( '' === $sha ) {
|
|
throw new RuntimeException( esc_html__( 'Unable to read branch head SHA.', 'wp-git-sync' ) );
|
|
}
|
|
return $sha;
|
|
}
|
|
|
|
/**
|
|
* Get the tree SHA for a commit.
|
|
*
|
|
* @param string $commit_sha Commit SHA.
|
|
* @return string Tree SHA.
|
|
*/
|
|
public function get_commit_tree_sha( string $commit_sha ): string {
|
|
$data = $this->client->request( 'GET', $this->api( '/git/commits/' . rawurlencode( $commit_sha ) ) );
|
|
$sha = isset( $data['tree']['sha'] ) ? (string) $data['tree']['sha'] : '';
|
|
if ( '' === $sha ) {
|
|
throw new RuntimeException( esc_html__( 'Unable to read commit tree SHA.', 'wp-git-sync' ) );
|
|
}
|
|
return $sha;
|
|
}
|
|
|
|
/**
|
|
* Create a blob.
|
|
*
|
|
* @param string $content File content (utf-8).
|
|
* @return string Blob SHA.
|
|
*/
|
|
public function create_blob( string $content ): string {
|
|
$data = $this->client->request( 'POST', $this->api( '/git/blobs' ), [
|
|
'content' => $content,
|
|
'encoding' => 'utf-8',
|
|
] );
|
|
$sha = isset( $data['sha'] ) ? (string) $data['sha'] : '';
|
|
if ( '' === $sha ) {
|
|
throw new RuntimeException( esc_html__( 'Unable to create blob.', 'wp-git-sync' ) );
|
|
}
|
|
return $sha;
|
|
}
|
|
|
|
/**
|
|
* Create a tree.
|
|
*
|
|
* @param string|null $base_tree_sha Base tree SHA.
|
|
* @param array<int,array{path:string,mode:string,type:string,sha:string|null}> $tree Tree items.
|
|
* @return string Tree SHA.
|
|
*/
|
|
public function create_tree( ?string $base_tree_sha, array $tree ): string {
|
|
// GitHub accepts sha=null for deletions.
|
|
$payload = [ 'tree' => $tree ];
|
|
if ( is_string( $base_tree_sha ) && '' !== trim( $base_tree_sha ) && self::EMPTY_TREE_SHA !== trim( $base_tree_sha ) ) {
|
|
$payload['base_tree'] = $base_tree_sha;
|
|
}
|
|
|
|
$data = $this->client->request( 'POST', $this->api( '/git/trees' ), $payload );
|
|
$sha = isset( $data['sha'] ) ? (string) $data['sha'] : '';
|
|
if ( '' === $sha ) {
|
|
throw new RuntimeException( esc_html__( 'Unable to create tree.', 'wp-git-sync' ) );
|
|
}
|
|
return $sha;
|
|
}
|
|
|
|
/**
|
|
* Create a commit.
|
|
*
|
|
* @param string $message Commit message.
|
|
* @param string $tree_sha Tree SHA.
|
|
* @param string|null $parent_commit_sha Parent commit SHA (or null for initial commit).
|
|
* @return string Commit SHA.
|
|
*/
|
|
public function create_commit( string $message, string $tree_sha, ?string $parent_commit_sha = null ): string {
|
|
$payload = [
|
|
'message' => $message,
|
|
'tree' => $tree_sha,
|
|
];
|
|
if ( is_string( $parent_commit_sha ) && '' !== trim( $parent_commit_sha ) ) {
|
|
$payload['parents'] = [ $parent_commit_sha ];
|
|
}
|
|
|
|
$data = $this->client->request( 'POST', $this->api( '/git/commits' ), $payload );
|
|
$sha = isset( $data['sha'] ) ? (string) $data['sha'] : '';
|
|
if ( '' === $sha ) {
|
|
throw new RuntimeException( esc_html__( 'Unable to create commit.', 'wp-git-sync' ) );
|
|
}
|
|
return $sha;
|
|
}
|
|
|
|
/**
|
|
* Create a branch ref.
|
|
*
|
|
* @param string $branch Branch name.
|
|
* @param string $commit_sha Commit SHA.
|
|
* @return void
|
|
*/
|
|
public function create_ref( string $branch, string $commit_sha ): void {
|
|
$this->client->request( 'POST', $this->api( '/git/refs' ), [
|
|
'ref' => 'refs/heads/' . $branch,
|
|
'sha' => $commit_sha,
|
|
] );
|
|
}
|
|
|
|
/**
|
|
* Update a branch ref to point at a commit.
|
|
*
|
|
* @param string $branch Branch name.
|
|
* @param string $commit_sha Commit SHA.
|
|
* @return void
|
|
*/
|
|
public function update_ref( string $branch, string $commit_sha ): void {
|
|
$this->client->request( 'PATCH', $this->api( '/git/refs/heads/' . rawurlencode( $branch ) ), [
|
|
'sha' => $commit_sha,
|
|
'force' => false,
|
|
] );
|
|
}
|
|
|
|
/**
|
|
* Ensure a branch exists and reset it to an empty tree.
|
|
*
|
|
* @param string $branch Branch name.
|
|
* @param string $message Commit message.
|
|
* @return void
|
|
*/
|
|
public function reset_branch_to_empty( string $branch, string $message ): void {
|
|
$branch = trim( $branch );
|
|
if ( '' === $branch ) {
|
|
throw new InvalidArgumentException( esc_html__( 'Branch is required.', 'wp-git-sync' ) );
|
|
}
|
|
|
|
$this->ensure_branch( $branch );
|
|
|
|
try {
|
|
$parent_commit = $this->get_ref_sha( $branch );
|
|
} catch ( Throwable $e ) {
|
|
if ( $this->is_empty_repo_error( $e ) ) {
|
|
$new_commit = $this->create_commit( $message, self::EMPTY_TREE_SHA, null );
|
|
try {
|
|
$this->create_ref( $branch, $new_commit );
|
|
} catch ( Throwable $ref_error ) {
|
|
$this->update_ref( $branch, $new_commit );
|
|
}
|
|
return;
|
|
}
|
|
throw $e;
|
|
}
|
|
|
|
$new_commit = $this->create_commit( $message, self::EMPTY_TREE_SHA, $parent_commit );
|
|
$this->update_ref( $branch, $new_commit );
|
|
}
|
|
|
|
/**
|
|
* Commit/update multiple files (path => content) on a branch.
|
|
*
|
|
* Implementation uses Git Data API:
|
|
* - create blobs
|
|
* - create tree
|
|
* - create commit
|
|
* - update ref
|
|
*
|
|
* @param string $branch Branch name.
|
|
* @param string $message Commit message.
|
|
* @param array<string,string> $files Files to write.
|
|
* @return array{commit_sha:string}
|
|
*/
|
|
public function commit_files( string $branch, string $message, array $files ): array {
|
|
return $this->commit_files_with_deletes( $branch, $message, $files, [] );
|
|
}
|
|
|
|
/**
|
|
* Commit/update files and delete stale paths in a single commit.
|
|
*
|
|
* Deletions are represented in the Git tree as entries with sha=null.
|
|
*
|
|
* @param string $branch Branch name.
|
|
* @param string $message Commit message.
|
|
* @param array<string,string> $files Files to write.
|
|
* @param string[] $delete_paths Repo-relative paths to delete.
|
|
* @return array{commit_sha:string}
|
|
*/
|
|
public function commit_files_with_deletes( string $branch, string $message, array $files, array $delete_paths ): array {
|
|
$step = 'start';
|
|
try {
|
|
$step = 'ensure_branch';
|
|
$this->ensure_branch( $branch );
|
|
|
|
$parent_commit = null;
|
|
$base_tree = null;
|
|
$is_initial_commit = false;
|
|
try {
|
|
$step = 'get_ref_sha';
|
|
$parent_commit = $this->get_ref_sha( $branch );
|
|
$step = 'get_commit_tree_sha';
|
|
$base_tree = $this->get_commit_tree_sha( $parent_commit );
|
|
} catch ( Throwable $e ) {
|
|
if ( $this->is_empty_repo_error( $e ) ) {
|
|
$is_initial_commit = true;
|
|
} else {
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
$tree_items = $this->build_tree_items_for_files( $files );
|
|
|
|
$can_apply_deletes = ! $is_initial_commit
|
|
&& is_string( $base_tree )
|
|
&& '' !== trim( $base_tree )
|
|
&& self::EMPTY_TREE_SHA !== trim( $base_tree );
|
|
|
|
if ( $can_apply_deletes ) {
|
|
$step = 'get_tree_paths_for_deletes';
|
|
$existing_delete_targets = array_fill_keys( $this->get_tree_paths( (string) $base_tree ), true );
|
|
foreach ( $delete_paths as $path ) {
|
|
$path = ltrim( (string) $path, '/' );
|
|
if ( '' === $path || ! isset( $existing_delete_targets[ $path ] ) ) {
|
|
continue;
|
|
}
|
|
$tree_items[] = [
|
|
'path' => $path,
|
|
'mode' => '100644',
|
|
'type' => 'blob',
|
|
'sha' => null,
|
|
];
|
|
}
|
|
}
|
|
|
|
$step = 'create_tree';
|
|
$new_tree = $this->create_tree( $base_tree, $tree_items );
|
|
$step = 'create_commit';
|
|
$new_commit = $this->create_commit( $message, $new_tree, $parent_commit );
|
|
if ( $is_initial_commit ) {
|
|
// First commit in an empty repo: branch ref does not exist yet.
|
|
try {
|
|
$step = 'create_ref';
|
|
$this->create_ref( $branch, $new_commit );
|
|
} catch ( Throwable $e ) {
|
|
// In case another writer created the ref concurrently, just update.
|
|
$step = 'update_ref_after_create_ref';
|
|
$this->update_ref( $branch, $new_commit );
|
|
}
|
|
} else {
|
|
$step = 'update_ref';
|
|
$this->update_ref( $branch, $new_commit );
|
|
}
|
|
|
|
return [ 'commit_sha' => $new_commit ];
|
|
} catch ( Throwable $e ) {
|
|
if ( ! $this->is_empty_repo_error( $e ) ) {
|
|
throw new RuntimeException(
|
|
sprintf(
|
|
/* translators: 1: Commit pipeline step name, 2: Error message. */
|
|
esc_html__( 'GitHub commit pipeline failed at step "%1$s": %2$s', 'wp-git-sync' ),
|
|
esc_html( $step ),
|
|
esc_html( $e->getMessage() )
|
|
)
|
|
);
|
|
}
|
|
|
|
// Last-resort bootstrap for empty repos if any earlier step still returned 409.
|
|
try {
|
|
$step = 'bootstrap_empty_repo';
|
|
return $this->bootstrap_empty_repo( $branch, $message, $files );
|
|
} catch ( Throwable $bootstrap_error ) {
|
|
throw new RuntimeException(
|
|
sprintf(
|
|
/* translators: 1: Commit pipeline step name, 2: Error message. */
|
|
esc_html__( 'GitHub commit pipeline failed at step "%1$s": %2$s', 'wp-git-sync' ),
|
|
esc_html( $step ),
|
|
esc_html( $bootstrap_error->getMessage() )
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bootstrap first commit for empty repos, then continue normal sync.
|
|
*
|
|
* This avoids relying on refs/heads existing in a brand-new repository.
|
|
*
|
|
* @param string $branch Branch name.
|
|
* @param string $message Commit message.
|
|
* @param array<string,string> $files Files to write.
|
|
* @return array{commit_sha:string}
|
|
*/
|
|
private function bootstrap_empty_repo( string $branch, string $message, array $files ): array {
|
|
if ( empty( $files ) ) {
|
|
throw new RuntimeException( esc_html__( 'Cannot initialize empty repository: no files to commit.', 'wp-git-sync' ) );
|
|
}
|
|
|
|
$first_path = (string) array_key_first( $files );
|
|
$first_content = (string) $files[ $first_path ];
|
|
unset( $files[ $first_path ] );
|
|
|
|
$init_commit = $this->create_initial_file_via_contents_api( $branch, $first_path, $first_content, __( 'Initialize repository for WP Git Sync', 'wp-git-sync' ) );
|
|
if ( empty( $files ) ) {
|
|
return [ 'commit_sha' => $init_commit ];
|
|
}
|
|
|
|
// Repo is initialized now. Apply remaining files in a normal commit.
|
|
return $this->commit_files_with_deletes( $branch, $message, $files, [] );
|
|
}
|
|
|
|
/**
|
|
* Create the very first file via the Contents API.
|
|
*
|
|
* @param string $branch Branch name.
|
|
* @param string $path Repo-relative file path.
|
|
* @param string $content File content.
|
|
* @param string $message Commit message.
|
|
* @return string Commit SHA.
|
|
*/
|
|
private function create_initial_file_via_contents_api( string $branch, string $path, string $content, string $message ): string {
|
|
$path = ltrim( $path, '/' );
|
|
$url = $this->api( '/contents/' . str_replace( '%2F', '/', rawurlencode( $path ) ) );
|
|
$payload = [
|
|
'message' => $message,
|
|
'content' => base64_encode( $content ),
|
|
'branch' => $branch,
|
|
];
|
|
|
|
try {
|
|
$data = $this->client->request( 'PUT', $url, $payload );
|
|
} catch ( Throwable $e ) {
|
|
// Some empty repos only accept the initial write on default branch.
|
|
$data = $this->client->request(
|
|
'PUT',
|
|
$url,
|
|
[
|
|
'message' => $message,
|
|
'content' => base64_encode( $content ),
|
|
]
|
|
);
|
|
}
|
|
|
|
$commit_sha = isset( $data['commit']['sha'] ) ? (string) $data['commit']['sha'] : '';
|
|
if ( '' === $commit_sha ) {
|
|
throw new RuntimeException( esc_html__( 'Unable to create initial commit in empty repository.', 'wp-git-sync' ) );
|
|
}
|
|
|
|
// Ensure selected branch exists and points to the initial commit.
|
|
try {
|
|
$this->create_ref( $branch, $commit_sha );
|
|
} catch ( Throwable $e ) {
|
|
try {
|
|
$this->update_ref( $branch, $commit_sha );
|
|
} catch ( Throwable $ignored ) {
|
|
// If branch already points correctly, we can continue.
|
|
}
|
|
}
|
|
|
|
return $commit_sha;
|
|
}
|
|
|
|
/**
|
|
* Build git tree entries for files to write.
|
|
*
|
|
* @param array<string,string> $files Files.
|
|
* @return array<int,array{path:string,mode:string,type:string,sha:string|null}>
|
|
*/
|
|
private function build_tree_items_for_files( array $files ): array {
|
|
$tree_items = [];
|
|
foreach ( $files as $path => $content ) {
|
|
$blob_sha = $this->create_blob( $content );
|
|
$tree_items[] = [
|
|
'path' => ltrim( (string) $path, '/' ),
|
|
'mode' => '100644',
|
|
'type' => 'blob',
|
|
'sha' => $blob_sha,
|
|
];
|
|
}
|
|
return $tree_items;
|
|
}
|
|
|
|
/**
|
|
* List file paths present in a tree.
|
|
*
|
|
* @param string $tree_sha Tree SHA.
|
|
* @return string[]
|
|
*/
|
|
private function get_tree_paths( string $tree_sha ): array {
|
|
$data = $this->client->request(
|
|
'GET',
|
|
$this->api( '/git/trees/' . rawurlencode( $tree_sha ) . '?recursive=1' )
|
|
);
|
|
$tree = isset( $data['tree'] ) && is_array( $data['tree'] ) ? $data['tree'] : [];
|
|
$paths = [];
|
|
foreach ( $tree as $item ) {
|
|
if ( ! is_array( $item ) ) {
|
|
continue;
|
|
}
|
|
$path = isset( $item['path'] ) ? ltrim( (string) $item['path'], '/' ) : '';
|
|
$type = isset( $item['type'] ) ? (string) $item['type'] : '';
|
|
if ( '' === $path || 'blob' !== $type ) {
|
|
continue;
|
|
}
|
|
$paths[] = $path;
|
|
}
|
|
return array_values( array_unique( $paths ) );
|
|
}
|
|
|
|
/**
|
|
* Build an API URL for this repo.
|
|
*
|
|
* @param string $path Path under the repo API.
|
|
* @return string
|
|
*/
|
|
private function api( string $path ): string {
|
|
$path = ltrim( $path, '/' );
|
|
$base = 'https://api.github.com/repos/' . $this->repo;
|
|
return '' === $path ? $base : ( $base . '/' . $path );
|
|
}
|
|
|
|
/**
|
|
* Detect GitHub's empty-repository response.
|
|
*
|
|
* @param Throwable $e Exception.
|
|
* @return bool
|
|
*/
|
|
private function is_empty_repo_error( Throwable $e ): bool {
|
|
$message = strtolower( $e->getMessage() );
|
|
return false !== strpos( $message, 'repository is empty' ) || false !== strpos( $message, 'git repository is empty' );
|
|
}
|
|
}
|