git-diffall — полный diff рабочего дерева ОДНИМ листингом
翻译
| #!/usr/bin/env ruby | |
| # frozen_string_literal: true | |
| # git-diffall — полный diff рабочего дерева ОДНИМ листингом. | |
| # Поведение повторяет bash-версию; полная справка — константа GitDiffAll::HELP. | |
| require 'open3' | |
| module GitDiffAll | |
| HELP = <<~TEXT | |
| git-diffall — полный diff рабочего дерева ОДНИМ листингом: | |
| * изменения tracked-файлов (только unstaged: working tree vs index) | |
| * содержимое untracked-файлов в формате unified diff | |
| Staged-изменения НЕ выводятся. Состояние репозитория НЕ меняется. | |
| Использование: git diffall [--stat] [-- ...] | |
| --stat только статистика (+/- по файлам) | |
| ... ограничить вывод этими путями | |
| Весь вывод проходит через ОДИН pager (${PAGER:-less}); при перенаправлении | |
| в файл/пайп — pager не запускается, отдаётся чистый текст. | |
| TEXT | |
| # Запрошена справка (-h/--help). | |
| class HelpRequested < StandardError; end | |
| # Не git-репозиторий. | |
| class NotGitRepositoryError < StandardError; end | |
| # Разбор аргументов командной строки. | |
| class Options | |
| attr_reader :paths | |
| def initialize(stat: false, paths: []) | |
| @stat = stat | |
| @paths = paths | |
| end | |
| def stat? | |
| @stat | |
| end | |
| def self.parse(argv) | |
| stat = false | |
| paths = [] | |
| args = argv.dup | |
| until args.empty? | |
| case args.shift | |
| when '--stat' then stat = true | |
| when '-h', '--help' then raise HelpRequested | |
| when '--' | |
| paths.concat(args) | |
| args.clear | |
| else | |
| paths << args.first | |
| end | |
| end | |
| new(stat: stat, paths: paths) | |
| end | |
| end | |
| # Тонкая обёртка над git: все вызовы без собственного pager'а, цвет включается | |
| # принудительно, только когда вывод идёт на терминал (через пайп git сам бы его | |
| # выключил). Ненулевой exit status git-команд — не ошибка (у diff он означает | |
| # «есть различия»), поэтому не проверяется, а stderr пробрасывается на терминал. | |
| class Git | |
| def initialize(force_color:, stderr: $stderr) | |
| @force_color = force_color | |
| @stderr = stderr | |
| end | |
| def repository? | |
| system(*base_args, 'rev-parse', '--git-dir', out: File::NULL, err: File::NULL) | |
| end | |
| # Изменения tracked-файлов: working tree vs index (без staged). | |
| def tracked_diff(paths) | |
| run('diff', *selection(paths)) | |
| end | |
| def tracked_stat(paths) | |
| run('diff', '--stat', *selection(paths)) | |
| end | |
| # Untracked-файлы (с исключением игнорируемых), NUL-разделитель — | |
| # безопасен для любых имён, включая переводы строк. | |
| def untracked_files(paths) | |
| out, = Open3.capture3(*base_args, 'ls-files', '--others', '--exclude-standard', | |
| '-z', *selection(paths)) | |
| out.split("\x0").reject(&:empty?) | |
| end | |
| # Diff одного untracked-файла против /dev/null (для git это «пустой файл»; | |
| # File::NULL на Linux совпадает с '/dev/null'). | |
| def untracked_diff(path) | |
| run('diff', '--no-index', File::NULL, path) | |
| end | |
| private | |
| def base_args | |
| args = ['git', '--no-pager'] | |
| args.push('-c', 'color.diff=always') if @force_color | |
| args | |
| end | |
| def selection(paths) | |
| paths.empty? ? [] : ['--', *paths] | |
| end | |
| def run(*) | |
| stdout, stderr, = Open3.capture3(*base_args, *) | |
| @stderr.write(stderr) unless stderr.empty? | |
| stdout | |
| end | |
| end | |
| # «Тело» вывода: секция по tracked-файлам + секции по untracked-файлам. | |
| # Подклассы задают содержимое секций и разделитель (шаблонный метод). | |
| class Body | |
| def initialize(git:, paths:) | |
| @git = git | |
| @paths = paths | |
| end | |
| def render | |
| ([tracked_section] + untracked_sections).join(separator) + terminator | |
| end | |
| private | |
| def separator | |
| '' | |
| end | |
| # Хвостовая строка всего вывода (пустая у DiffBody). | |
| def terminator | |
| '' | |
| end | |
| def tracked_section | |
| raise NotImplementedError | |
| end | |
| def untracked_sections | |
| raise NotImplementedError | |
| end | |
| end | |
| # Полный diff: git diff + по одному unified-diff на untracked-файл. | |
| class DiffBody < Body | |
| private | |
| def tracked_section | |
| @git.tracked_diff(@paths) | |
| end | |
| def untracked_sections | |
| @git.untracked_files(@paths).map { |file| @git.untracked_diff(file) } | |
| end | |
| end | |
| # Статистика: git diff --stat + счётчик строк по untracked-файлам. | |
| # Секции — строки без хвостовых переводов строк: разделитель и терминатор | |
| # задают «склейку» точно как в bash (printf с \n + echo). | |
| class StatBody < Body | |
| private | |
| def separator | |
| "\n" | |
| end | |
| def terminator | |
| "\n" | |
| end | |
| def tracked_section | |
| @git.tracked_stat(@paths) | |
| end | |
| def untracked_sections | |
| @git.untracked_files(@paths).map do |file| | |
| format(' %s | %d ++++', file: file, count: line_count(file)) | |
| end | |
| end | |
| def line_count(file) | |
| File.foreach(file).count | |
| end | |
| end | |
| # Один pager на весь вывод; при перенаправлении в файл/пайп не запускается. | |
| module Pager | |
| extend self | |
| def interactive? | |
| $stdout.tty? | |
| end | |
| def command | |
| pager = ENV['GIT_PAGER'] || ENV['PAGER'] || 'less' | |
| pager == 'less' ? 'less -FRX' : pager | |
| end | |
| def write(output) | |
| IO.popen(command, 'w') { |io| io.write(output) } | |
| rescue Errno::EPIPE, Errno::ENOENT, Errno::EACCES | |
| # Pager закрылся раньше (q) или не найден — как bash-овское `|| true`, | |
| # это не ошибка. | |
| end | |
| end | |
| # Оркестрация: проверка репозитория, разбор аргументов, сбор вывода, pager. | |
| class CLI | |
| def initialize(argv, stdout: $stdout, stderr: $stderr) | |
| @argv = argv | |
| @stdout = stdout | |
| @stderr = stderr | |
| end | |
| def run | |
| git = Git.new(force_color: Pager.interactive?) | |
| raise NotGitRepositoryError unless git.repository? | |
| options = Options.parse(@argv) | |
| body = if options.stat? | |
| StatBody.new(git: git, paths: options.paths) | |
| else | |
| DiffBody.new(git: git, paths: options.paths) | |
| end | |
| if Pager.interactive? | |
| Pager.write(body.render) | |
| else | |
| @stdout.write(body.render) | |
| end | |
| 0 | |
| rescue HelpRequested | |
| @stdout.write(HELP) | |
| 0 | |
| rescue NotGitRepositoryError | |
| @stderr.puts('git-diffall: не git-репозиторий') | |
| 1 | |
| end | |
| end | |
| end | |
| # Скрипт стоит вне Rails-приложения, поэтому exit здесь уместен. | |
| exit GitDiffAll::CLI.new(ARGV).run # rubocop:disable Rails/Exit |
评论
?
参与讨论