common/lib/dependabot/workspace/git.rb
# typed: strict
# frozen_string_literal: true

require "sorbet-runtime"
require "dependabot/workspace/base"
require "dependabot/workspace/change_attempt"

module Dependabot
  module Workspace
    class Git < Base
      extend T::Sig
      extend T::Helpers

      USER = "dependabot[bot]"
      EMAIL = T.let("#{USER}@users.noreply.github.com".freeze, String)

      sig { returns(String) }
      attr_reader :initial_head_sha

      sig { params(path: T.any(Pathname, String)).void }
      def initialize(path)
        super
        @initial_head_sha = T.let(head_sha, String)
        configure_git
      end

      sig { returns(T::Boolean) }
      def changed?
        changes.any? || !changed_files(ignored_mode: "no").empty?
      end

      sig { override.returns(String) }
      def to_patch
        run_shell_command(
          "git diff --patch #{@initial_head_sha}.. .",
          fingerprint: "git diff --path <initial_head_sha>.. ."
        )
      end

      sig { override.returns(NilClass) }
      def reset!
        reset(initial_head_sha)
        clean
        run_shell_command("git stash clear")
        @change_attempts = []

        nil
      end

      sig do
        override
          .params(memo: T.nilable(String))
          .returns(T.nilable(T::Array[Dependabot::Workspace::ChangeAttempt]))
      end
      def store_change(memo = nil)
        return nil if changed_files(ignored_mode: "no").empty?

        debug("store_change - before: #{current_commit}")
        sha = commit(memo)

        change_attempts << ChangeAttempt.new(self, id: sha, memo: memo)
      ensure
        debug("store_change - after: #{current_commit}")
      end

      protected

      sig do
        override
          .params(memo: T.nilable(String), error: T.nilable(StandardError))
          .returns(T.nilable(T::Array[Dependabot::Workspace::ChangeAttempt]))
      end
      def capture_failed_change_attempt(memo = nil, error = nil)
        return nil if changed_files(ignored_mode: "matching").empty? && error.nil?

        sha = stash(memo)
        change_attempts << ChangeAttempt.new(self, id: sha, memo: memo, error: error)
      end

      private

      sig { returns(String) }
      def configure_git
        run_shell_command(%(git config user.name "#{USER}"), allow_unsafe_shell_command: true)
        run_shell_command(%(git config user.email "#{EMAIL}"), allow_unsafe_shell_command: true)
      end

      sig { returns(String) }
      def head_sha
        run_shell_command("git rev-parse HEAD", stderr_to_stdout: false).strip
      end

      sig { returns(String) }
      def last_stash_sha
        run_shell_command("git rev-parse refs/stash").strip
      end

      sig { returns(String) }
      def current_commit
        # Avoid emitting the user's commit message to logs if Dependabot hasn't made any changes
        return "Initial SHA: #{initial_head_sha}" if changes.empty?

        # Prints out the last commit in the format "<short-ref> <commit-message>"
        run_shell_command(%(git log -1 --pretty="%h% B"), allow_unsafe_shell_command: true).strip
      end

      sig { params(ignored_mode: String).returns(String) }
      def changed_files(ignored_mode: "traditional")
        run_shell_command(
          "git status --untracked-files=all --ignored=#{ignored_mode} --short .",
          fingerprint: "git status --untracked-files=all --ignored=<ignored_mode> --short ."
        ).strip
      end

      sig { params(memo: T.nilable(String)).returns(String) }
      def stash(memo = nil)
        msg = memo || "workspace change attempt"
        run_shell_command("git add --all --force .")
        run_shell_command(
          %(git stash push --all -m "#{msg}"),
          fingerprint: "git stash push --all -m \"<msg>\"",
          allow_unsafe_shell_command: true
        )

        last_stash_sha
      end

      sig { params(memo: T.nilable(String)).returns(String) }
      def commit(memo = nil)
        run_shell_command("git add #{path}")

        msg = memo || "workspace change"
        run_shell_command(
          %(git commit -m "#{msg}"),
          fingerprint: "git commit -m \"<msg>\"",
          allow_unsafe_shell_command: true
        )

        head_sha
      end

      sig { params(sha: String).returns(String) }
      def reset(sha)
        run_shell_command(
          "git reset --hard #{sha}",
          fingerprint: "git reset --hard <sha>"
        )
      end

      sig { override.returns(String) }
      def clean
        run_shell_command("git clean -fx .")
      end

      sig { params(args: String, kwargs: T.any(T::Boolean, String)).returns(String) }
      def run_shell_command(*args, **kwargs)
        Dir.chdir(path) { T.unsafe(SharedHelpers).run_shell_command(*args, **kwargs) }
      end

      sig { params(message: String).void }
      def debug(message)
        Dependabot.logger.debug("[workspace] #{message}")
      end
    end
  end
end
