Last active
August 13, 2026 17:37
-
-
Save sergio-fry/79c629823e85368fbd905c334a9a1446 to your computer and use it in GitHub Desktop.
git-diffall — полный diff рабочего дерева ОДНИМ листингом
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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] [-- <path>...] | |
| --stat только статистика (+/- по файлам) | |
| <path>... ограничить вывод этими путями | |
| Весь вывод проходит через ОДИН 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(' %<file>s | %<count>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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment