use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context as _, anyhow};
use async_trait::async_trait;
use gitlab::AsyncGitlab;
use gitlab::api::projects::repository::commits::{CommitAction, CommitActionType, CreateCommit};
use gitlab::api::projects::repository::files::Encoding;
use gitlab::api::projects::repository::tags::CreateTag;
use gitlab::api::{ApiError, AsyncQuery};
use serde::Deserialize;
use tokio::sync::Mutex;
use crate::command::CommandRunner;
use crate::filesystem::Filesystem;
use crate::forge::gitlab::GitLabProject;
use crate::git::Git;
use crate::git::ref_format::{validate_branch_name, validate_tag_name};
use crate::path::AbsolutePath;
use crate::redact::redact_credentials;
pub(crate) struct PendingCommit {
pub(crate) branch: String,
pub(crate) parent_sha: String,
pub(crate) message: String,
pub(crate) paths: Vec<PathBuf>,
}
pub(crate) struct PendingTag {
pub(crate) sha: String,
pub(crate) message: String,
}
pub(crate) struct State {
pub(crate) staged: Vec<PathBuf>,
pub(crate) pending: Option<PendingCommit>,
pub(crate) tags: HashMap<String, PendingTag>,
}
pub struct GitLabSignedCommit {
inner: Arc<dyn Git>,
fs: Arc<dyn Filesystem>,
client: Arc<AsyncGitlab>,
runner: Arc<dyn CommandRunner>,
project: GitLabProject,
dry_run: bool,
pub(crate) state: Mutex<State>,
}
impl std::fmt::Debug for GitLabSignedCommit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitLabSignedCommit")
.field("project", &self.project)
.field("dry_run", &self.dry_run)
.finish_non_exhaustive()
}
}
impl GitLabSignedCommit {
pub fn new(
inner: Arc<dyn Git>,
fs: Arc<dyn Filesystem>,
client: Arc<AsyncGitlab>,
runner: Arc<dyn CommandRunner>,
project: GitLabProject,
dry_run: bool,
) -> Self {
Self {
inner,
fs,
client,
runner,
project,
dry_run,
state: Mutex::new(State {
staged: Vec::new(),
pending: None,
tags: HashMap::new(),
}),
}
}
fn project_path(&self) -> String {
format!("{}/{}", self.project.group, self.project.project)
}
async fn flush(&self, pending: PendingCommit, force: bool) -> anyhow::Result<()> {
validate_branch_name(&pending.branch)?;
let actions =
build_commit_actions(&*self.fs, &*self.inner, &pending.paths, self.inner.path())
.await?;
self.post_commit_to_api(&pending, force, actions).await?;
self.sync_local_tree(&pending.branch).await
}
async fn post_commit_to_api(
&self,
pending: &PendingCommit,
force: bool,
actions: Vec<CommitAction<'static>>,
) -> anyhow::Result<()> {
let endpoint = CreateCommit::builder()
.project(self.project_path())
.branch(pending.branch.clone())
.commit_message(pending.message.clone())
.start_sha(pending.parent_sha.clone())
.force(force)
.actions(actions)
.build()
.map_err(|e| anyhow!(e.to_string()))
.with_context(|| {
format!(
"failed to build GitLab commit request for branch '{}'",
pending.branch
)
})?;
let response: CreateCommitResponse = endpoint
.query_async(&*self.client)
.await
.map_err(|e| {
let raw = format!("{e}");
anyhow!("{}", redact_credentials(&raw).into_owned())
})
.with_context(|| {
format!(
"failed to create signed GitLab commit on branch '{}' of project {}",
pending.branch,
self.project_path(),
)
})?;
log::info!("Created signed GitLab commit {}", response.id);
Ok(())
}
async fn sync_local_tree(&self, branch: &str) -> anyhow::Result<()> {
let cwd = self.inner.path().as_path();
let fetch = self
.runner
.run_mut("git", &["fetch", "origin", branch], cwd)
.await
.context("git fetch failed after GitLab API commit")?;
if !fetch.status.success() {
let raw = String::from_utf8_lossy(&fetch.stderr);
let stderr = redact_credentials(&raw);
anyhow::bail!("git fetch origin {branch} failed: {stderr}");
}
let reset = self
.runner
.run_mut("git", &["reset", "--hard", "FETCH_HEAD"], cwd)
.await
.context("git reset failed after GitLab API commit")?;
if !reset.status.success() {
let raw = String::from_utf8_lossy(&reset.stderr);
let stderr = redact_credentials(&raw);
anyhow::bail!("git reset --hard FETCH_HEAD failed: {stderr}");
}
Ok(())
}
}
#[derive(Deserialize)]
struct CreateCommitResponse {
id: String,
}
async fn build_commit_actions(
fs: &dyn Filesystem,
inner: &dyn Git,
staged: &[PathBuf],
repo_root: &AbsolutePath,
) -> anyhow::Result<Vec<CommitAction<'static>>> {
let mut actions: Vec<CommitAction<'static>> = Vec::with_capacity(staged.len());
for path in staged {
let rel = path.strip_prefix(repo_root.as_path()).with_context(|| {
format!(
"staged path {} is not under repo root {}",
path.display(),
repo_root.as_path().display()
)
})?;
let rel_str = rel.to_string_lossy().into_owned();
let abs = AbsolutePath::new(path)
.with_context(|| format!("staged path is not absolute: {}", path.display()))?;
if !fs.exists(&abs).await? {
let action = CommitAction::builder()
.action(CommitActionType::Delete)
.file_path(rel_str)
.build()
.map_err(|e| anyhow!(e.to_string()))
.context("failed to build GitLab delete action")?;
actions.push(action);
continue;
}
let bytes = fs.read(&abs).await?;
let action_type = if inner.path_exists_at_head(rel).await? {
CommitActionType::Update
} else {
CommitActionType::Create
};
let action = CommitAction::builder()
.action(action_type)
.file_path(rel_str)
.content(bytes)
.encoding(Encoding::Base64)
.build()
.map_err(|e| anyhow!(e.to_string()))
.context("failed to build GitLab create/update action")?;
actions.push(action);
}
Ok(actions)
}
#[async_trait]
impl Git for GitLabSignedCommit {
fn path(&self) -> &AbsolutePath {
self.inner.path()
}
async fn head_sha(&self) -> anyhow::Result<String> {
self.inner.head_sha().await
}
async fn path_exists_at_head(&self, path: &Path) -> anyhow::Result<bool> {
self.inner.path_exists_at_head(path).await
}
async fn is_dirty(&self) -> anyhow::Result<bool> {
self.inner.is_dirty().await
}
async fn current_branch(&self) -> anyhow::Result<Option<String>> {
self.inner.current_branch().await
}
async fn tag_exists(&self, tag: &str) -> anyhow::Result<bool> {
self.inner.tag_exists(tag).await
}
async fn remote_origin_url(&self) -> anyhow::Result<Option<String>> {
self.inner.remote_origin_url().await
}
async fn rev_list_count(&self, range: &str) -> anyhow::Result<usize> {
self.inner.rev_list_count(range).await
}
async fn log_message(&self, rev: &str) -> anyhow::Result<String> {
self.inner.log_message(rev).await
}
async fn log_subject(&self, rev: &str) -> anyhow::Result<String> {
self.inner.log_subject(rev).await
}
async fn log_added_commit(&self, path: &Path) -> anyhow::Result<Option<String>> {
self.inner.log_added_commit(path).await
}
async fn diff_tree_names(&self, commit: &str) -> anyhow::Result<Vec<String>> {
self.inner.diff_tree_names(commit).await
}
async fn diff_names(&self, extra_args: &[&str]) -> anyhow::Result<Vec<String>> {
self.inner.diff_names(extra_args).await
}
async fn add(&self, files: &[PathBuf]) -> anyhow::Result<()> {
self.inner.add(files).await?;
let mut state = self.state.lock().await;
state.staged.extend_from_slice(files);
Ok(())
}
async fn commit(&self, message: &str) -> anyhow::Result<()> {
if self.dry_run {
log::info!("(dry-run) would create signed GitLab commit: {message}");
return Ok(());
}
let staged = {
let mut state = self.state.lock().await;
if state.staged.is_empty() {
return Ok(());
}
std::mem::take(&mut state.staged)
};
let mut seen = std::collections::HashSet::new();
let paths: Vec<PathBuf> = staged
.into_iter()
.filter(|p| seen.insert(p.clone()))
.collect();
let branch = self
.inner
.current_branch()
.await?
.context("cannot create signed GitLab commit with a detached HEAD")?;
let parent_sha = self.inner.head_sha().await?;
let mut state = self.state.lock().await;
state.pending = Some(PendingCommit {
branch,
parent_sha,
message: message.to_string(),
paths,
});
Ok(())
}
async fn tag(&self, tag_name: &str, message: &str) -> anyhow::Result<()> {
validate_tag_name(tag_name)?;
if self.dry_run {
log::debug!("(dry-run) skipping tag record for {tag_name}");
return Ok(());
}
let sha = self.inner.head_sha().await?;
let mut state = self.state.lock().await;
state.tags.insert(
tag_name.to_string(),
PendingTag {
sha,
message: message.to_string(),
},
);
Ok(())
}
async fn push(&self) -> anyhow::Result<()> {
if self.dry_run {
log::info!("(dry-run) would push via GitLab API");
return Ok(());
}
let pending = self.state.lock().await.pending.take();
match pending {
Some(p) => self.flush(p, false).await,
None => self.inner.push().await,
}
}
async fn checkout(&self, branch: &str) -> anyhow::Result<()> {
self.inner.checkout(branch).await
}
async fn checkout_or_reset_branch(&self, branch: &str) -> anyhow::Result<()> {
self.inner.checkout_or_reset_branch(branch).await
}
async fn force_push_branch(&self, branch: &str) -> anyhow::Result<()> {
if self.dry_run {
log::info!("(dry-run) would force-push branch {branch} via GitLab API");
return Ok(());
}
let pending = self.state.lock().await.pending.take();
match pending {
Some(p) => {
let pending = PendingCommit {
branch: branch.to_string(),
..p
};
self.flush(pending, true).await
}
None => self.inner.force_push_branch(branch).await,
}
}
async fn delete_tag(&self, tag: &str) -> anyhow::Result<()> {
log::debug!("(api) skipping local tag deletion for {tag}; nothing to clean up");
Ok(())
}
async fn push_tag(&self, tag: &str) -> anyhow::Result<()> {
validate_tag_name(tag)?;
if self.dry_run {
log::info!("(dry-run) would create tag {tag} via GitLab API");
return Ok(());
}
let PendingTag { sha, message } =
self.state.lock().await.tags.remove(tag).with_context(|| {
format!("no pending tag '{tag}' to push; tag() must be called before push_tag()")
})?;
let mut builder = CreateTag::builder();
builder.project(self.project_path()).tag_name(tag).ref_(sha);
if !message.is_empty() {
builder.message(message);
}
let endpoint = builder
.build()
.map_err(|e| anyhow!(e.to_string()))
.with_context(|| format!("failed to build GitLab tag request for '{tag}'"))?;
match gitlab::api::ignore(endpoint)
.query_async(&*self.client)
.await
{
Ok(()) => {
log::info!("Created tag {tag} via GitLab API");
Ok(())
}
Err(ApiError::GitlabWithStatus { status, msg })
if status.as_u16() == 400 && msg.contains("already exists") =>
{
log::info!("Tag {tag} already exists on the remote, skipping");
Ok(())
}
Err(ApiError::GitlabObjectWithStatus { status, obj })
if status.as_u16() == 400 && obj.to_string().contains("already exists") =>
{
log::info!("Tag {tag} already exists on the remote, skipping");
Ok(())
}
Err(e) => {
let raw = format!("{e}");
let redacted = redact_credentials(&raw);
Err(anyhow!("{}", redacted.into_owned())).with_context(|| {
format!(
"failed to create tag '{tag}' on project {}",
self.project_path()
)
})
}
}
}
}