Created
April 14, 2026 15:30
-
-
Save KristofferC/1e3e05fc603da67313257a8da5d938a7 to your computer and use it in GitHub Desktop.
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 julia | |
| # | |
| #===- git-runic - Runic Git Integration ----------------------*- julia -*--===# | |
| # | |
| # Based on `git-clang-format`, which is part of the LLVM Project, under the | |
| # Apache License v2.0 with LLVM Exceptions (see https://llvm.org/LICENSE.txt). | |
| # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception | |
| # | |
| #===------------------------------------------------------------------------===# | |
| """ | |
| Runic git integration | |
| ============================ | |
| This file provides a Runic integration for git. Put it somewhere in your | |
| path and ensure that it is executable. Then, "git runic" will invoke | |
| Runic on the changes in current files or a specific commit. | |
| For further details, run: | |
| git runic -h | |
| """ | |
| const USAGE = "git runic [OPTIONS] [<commit>] [<commit>|--staged] [--] [<file>...]" | |
| const DESC = """ | |
| If zero or one commits are given, run runic on all lines that differ | |
| between the working directory and <commit>, which defaults to HEAD. Changes are | |
| only applied to the working directory, or in the stage/index. | |
| Examples: | |
| To format staged changes, i.e everything that's been `git add`ed: | |
| git runic | |
| To also format everything touched in the most recent commit: | |
| git runic HEAD~1 | |
| If you're on a branch off main, to format everything touched on your branch: | |
| git runic main | |
| If two commits are given (requires --diff), run runic on all lines in the | |
| second <commit> that differ from the first <commit>. | |
| The following git-config settings set the default of the corresponding option: | |
| runic.julia | |
| runic.project | |
| runic.commit | |
| runic.extensions | |
| """ | |
| # Name of the temporary index file in which save the output of runic. | |
| # This file is created within the .git directory. | |
| const TEMP_INDEX_BASENAME = "runic-index" | |
| struct Range | |
| start::Int | |
| count::Int | |
| end | |
| function die(message) | |
| printstyled(stderr, "error: ", message, "\n"; color = :red) | |
| exit(2) | |
| end | |
| function run_git(args...; verbose = true, strip_output = true) | |
| cmd = `git $args` | |
| out = IOBuffer() | |
| err = IOBuffer() | |
| p = run(pipeline(cmd; stdout = out, stderr = err); wait = false) | |
| wait(p) | |
| stdout_str = String(take!(out)) | |
| stderr_str = String(take!(err)) | |
| if p.exitcode == 0 | |
| if !isempty(stderr_str) | |
| if verbose | |
| print(stderr, "`$cmd` printed to stderr:\n") | |
| end | |
| print(stderr, rstrip(stderr_str, ['\r', '\n']), "\n") | |
| end | |
| if strip_output | |
| stdout_str = rstrip(stdout_str, ['\r', '\n']) | |
| end | |
| return stdout_str | |
| end | |
| if verbose | |
| print(stderr, "`$cmd` returned $(p.exitcode)\n") | |
| end | |
| if !isempty(stderr_str) | |
| print(stderr, rstrip(stderr_str, ['\r', '\n']), "\n") | |
| end | |
| exit(2) | |
| end | |
| function load_git_config() | |
| out = Dict{String, String}() | |
| raw = run_git("config", "--list", "--null") | |
| for entry in split(raw, '\0') | |
| isempty(entry) && continue | |
| if occursin('\n', entry) | |
| name, value = split(entry, '\n'; limit = 2) | |
| out[name] = value | |
| else | |
| out[entry] = "true" | |
| end | |
| end | |
| return out | |
| end | |
| function get_object_type(value) | |
| try | |
| out = IOBuffer() | |
| err = IOBuffer() | |
| p = run(pipeline(`git cat-file -t $value`; stdout = out, stderr = err); wait = false) | |
| wait(p) | |
| if p.exitcode != 0 | |
| return nothing | |
| end | |
| return strip(String(take!(out))) | |
| catch | |
| return nothing | |
| end | |
| end | |
| function interpret_args(args, dash_dash, default_commit) | |
| if !isempty(dash_dash) | |
| if isempty(args) | |
| commits = [default_commit] | |
| else | |
| commits = copy(args) | |
| end | |
| for commit in commits | |
| otype = get_object_type(commit) | |
| if otype ∉ ("commit", "tag") | |
| if otype === nothing | |
| die("'$commit' is not a commit") | |
| else | |
| die("'$commit' is a $otype, but a commit was expected") | |
| end | |
| end | |
| end | |
| files = dash_dash[2:end] # skip the "--" itself | |
| elseif !isempty(args) | |
| commits = String[] | |
| remaining = copy(args) | |
| while !isempty(remaining) | |
| if !disambiguate_revision(remaining[1]) | |
| break | |
| end | |
| push!(commits, popfirst!(remaining)) | |
| end | |
| if isempty(commits) | |
| commits = [default_commit] | |
| end | |
| files = remaining | |
| else | |
| commits = [default_commit] | |
| files = String[] | |
| end | |
| return commits, files | |
| end | |
| function disambiguate_revision(value) | |
| run_git("rev-parse", value; verbose = false) | |
| otype = get_object_type(value) | |
| if otype === nothing | |
| return false | |
| end | |
| if otype ∈ ("commit", "tag") | |
| return true | |
| end | |
| die("`$value` is a $otype, but a commit or filename was expected") | |
| end | |
| function compute_diff(commits, files, staged, diff_common_commit) | |
| git_tool = "diff-index" | |
| extra_args = String[] | |
| if length(commits) == 2 | |
| git_tool = "diff-tree" | |
| if diff_common_commit | |
| commits = ["$(commits[1])...$(commits[2])"] | |
| end | |
| elseif staged | |
| push!(extra_args, "--cached") | |
| end | |
| cmd_args = [git_tool, "-p", "-U0", extra_args..., commits..., "--", files...] | |
| cmd = `git $cmd_args` | |
| return open(cmd, "r") | |
| end | |
| function extract_lines(patch_io) | |
| matches = Dict{String, Vector{Range}}() | |
| filename = "" | |
| for line in eachline(patch_io) | |
| m = match(r"^\+\+\+\ [^/]+/(.*)", line) | |
| if m !== nothing | |
| filename = rstrip(m.captures[1], ['\r', '\n', '\t']) | |
| end | |
| m = match(r"^@@ -[0-9,]+ \+(\d+)(,(\d+))?", line) | |
| if m !== nothing | |
| start_line = parse(Int, m.captures[1]) | |
| line_count = m.captures[3] !== nothing ? parse(Int, m.captures[3]) : 1 | |
| if line_count == 0 | |
| line_count = 1 | |
| end | |
| if start_line == 0 | |
| continue | |
| end | |
| ranges = get!(matches, filename, Range[]) | |
| push!(ranges, Range(start_line, line_count)) | |
| end | |
| end | |
| return matches | |
| end | |
| function compute_diff_and_extract_lines(commits, files, staged, diff_common_commit) | |
| diff_process = compute_diff(commits, files, staged, diff_common_commit) | |
| changed_lines = extract_lines(diff_process) | |
| close(diff_process) | |
| return changed_lines | |
| end | |
| function filter_by_extension!(dict, allowed_extensions) | |
| allowed = Set(lowercase.(allowed_extensions)) | |
| for filename in collect(keys(dict)) | |
| parts = rsplit(filename, '.'; limit = 2) | |
| if length(parts) == 1 | |
| if "" ∈ allowed | |
| continue | |
| end | |
| delete!(dict, filename) | |
| elseif lowercase(parts[2]) ∉ allowed | |
| delete!(dict, filename) | |
| end | |
| end | |
| end | |
| function filter_symlinks!(dict) | |
| for filename in collect(keys(dict)) | |
| if islink(filename) | |
| delete!(dict, filename) | |
| end | |
| end | |
| end | |
| function cd_to_toplevel() | |
| toplevel = run_git("rev-parse", "--show-toplevel") | |
| cd(toplevel) | |
| end | |
| function create_tree(input_lines, mode) | |
| @assert mode ∈ ("--stdin", "--index-info") | |
| gitdir = run_git("rev-parse", "--git-dir") | |
| index_path = joinpath(gitdir, TEMP_INDEX_BASENAME) | |
| old_index = get(ENV, "GIT_INDEX_FILE", nothing) | |
| try | |
| # Create empty index | |
| run_git("read-tree", "--index-output=$index_path", "--empty") | |
| ENV["GIT_INDEX_FILE"] = index_path | |
| cmd = `git update-index --add -z $mode` | |
| p = open(cmd, "w") | |
| for line in input_lines | |
| write(p, line, '\0') | |
| end | |
| close(p) | |
| if !success(p) | |
| die("`git update-index --add -z $mode` failed") | |
| end | |
| tree_id = run_git("write-tree") | |
| return tree_id | |
| finally | |
| if old_index === nothing | |
| delete!(ENV, "GIT_INDEX_FILE") | |
| else | |
| ENV["GIT_INDEX_FILE"] = old_index | |
| end | |
| isfile(index_path) && rm(index_path) | |
| end | |
| end | |
| function create_tree_from_workdir(filenames) | |
| return create_tree(filenames, "--stdin") | |
| end | |
| function create_tree_from_index(filenames) | |
| saved_env = copy(ENV) | |
| lines = String[] | |
| for filename in filenames | |
| out = IOBuffer() | |
| run(pipeline(`git ls-files --stage -z -- $filename`; stdout = out)) | |
| stdout_str = String(take!(out)) | |
| parts = split(stdout_str, '\0') | |
| push!(lines, parts[1]) | |
| end | |
| return create_tree(lines, "--index-info") | |
| end | |
| function runic_to_blob(filename, line_ranges; revision = nothing, | |
| julia_cmd = "julia", project = "@runic", env = nothing) | |
| cmd_args = [julia_cmd, "--startup-file=no", "--project=$project", "-e", | |
| "using Runic; exit(Runic.main(ARGS))", "--"] | |
| for r in line_ranges | |
| push!(cmd_args, "--lines=$(r.start):$(r.start + r.count - 1)") | |
| end | |
| if revision !== nothing | |
| # Format from git object | |
| rev_spec = "$revision:$filename" | |
| git_show = open(`git cat-file blob $rev_spec`, "r") | |
| if isempty(revision) | |
| # Reading from index — need original env | |
| end | |
| runic_proc = open(pipeline(Cmd(cmd_args); stdin = git_show, stderr = stderr), "r") | |
| else | |
| push!(cmd_args, filename) | |
| runic_proc = open(pipeline(Cmd(cmd_args); stderr = stderr), "r") | |
| end | |
| hash_out = IOBuffer() | |
| hash_proc = run(pipeline(`git hash-object -w --path=$filename --stdin`; | |
| stdin = runic_proc, stdout = hash_out); wait = false) | |
| wait(hash_proc) | |
| close(runic_proc) | |
| if revision !== nothing | |
| close(git_show) | |
| end | |
| if hash_proc.exitcode != 0 | |
| die("`git hash-object -w --path=$filename --stdin` failed") | |
| end | |
| return strip(String(take!(hash_out))) | |
| end | |
| function run_runic_and_save_to_tree(changed_lines; revision = nothing, | |
| julia_cmd = "julia", project = "@runic") | |
| env = revision == "" ? copy(ENV) : nothing | |
| lines = String[] | |
| for (filename, line_ranges) in changed_lines | |
| if revision !== nothing | |
| if !isempty(revision) | |
| dir = dirname(filename) | |
| base = basename(filename) | |
| out = IOBuffer() | |
| run(pipeline(`git ls-tree $(revision):$(dir) $base`; stdout = out)) | |
| stdout_str = String(take!(out)) | |
| mode = string(parse(Int, split(stdout_str)[1]; base = 8); base = 8) | |
| else | |
| out = IOBuffer() | |
| run(pipeline(`git ls-files --stage -- $filename`; stdout = out)) | |
| stdout_str = String(take!(out)) | |
| mode = string(parse(Int, split(stdout_str)[1]; base = 8); base = 8) | |
| end | |
| else | |
| mode = string(stat(filename).mode; base = 8) | |
| end | |
| # Ensure mode has leading zero like git expects (e.g., "0100644" -> "100644") | |
| # Julia's oct formatting doesn't add "0o" prefix with string() | |
| blob_id = runic_to_blob(filename, line_ranges; | |
| revision = revision, | |
| julia_cmd = julia_cmd, | |
| project = project, | |
| env = env) | |
| push!(lines, "$mode $blob_id\t$filename") | |
| end | |
| return create_tree(lines, "--index-info") | |
| end | |
| function print_diff(old_tree, new_tree) | |
| p = run(ignorestatus(`git diff --diff-filter=M --exit-code $old_tree $new_tree`)) | |
| return p.exitcode | |
| end | |
| function print_diffstat(old_tree, new_tree) | |
| p = run(ignorestatus(`git diff --diff-filter=M --exit-code --stat $old_tree $new_tree`)) | |
| return p.exitcode | |
| end | |
| function apply_changes(old_tree, new_tree; force = false, patch_mode = false) | |
| raw = run_git("diff-tree", "--diff-filter=M", "-r", "-z", "--name-only", | |
| old_tree, new_tree) | |
| changed_files = filter(!isempty, split(rstrip(raw, '\0'), '\0')) | |
| if !force | |
| unstaged = run_git("diff-files", "--name-status", changed_files...) | |
| if !isempty(unstaged) | |
| print(stderr, "The following files would be modified but have unstaged changes:\n") | |
| print(stderr, unstaged, "\n") | |
| print(stderr, "Please commit, stage, or stash them first.\n") | |
| exit(2) | |
| end | |
| end | |
| if patch_mode | |
| gitdir = run_git("rev-parse", "--git-dir") | |
| index_path = joinpath(gitdir, TEMP_INDEX_BASENAME) | |
| old_index = get(ENV, "GIT_INDEX_FILE", nothing) | |
| try | |
| run_git("read-tree", "--index-output=$index_path", old_tree) | |
| ENV["GIT_INDEX_FILE"] = index_path | |
| run(`git checkout --patch $new_tree`) | |
| finally | |
| if old_index === nothing | |
| delete!(ENV, "GIT_INDEX_FILE") | |
| else | |
| ENV["GIT_INDEX_FILE"] = old_index | |
| end | |
| isfile(index_path) && rm(index_path) | |
| end | |
| else | |
| gitdir = run_git("rev-parse", "--git-dir") | |
| index_path = joinpath(gitdir, TEMP_INDEX_BASENAME) | |
| old_index = get(ENV, "GIT_INDEX_FILE", nothing) | |
| try | |
| run_git("read-tree", "--index-output=$index_path", new_tree) | |
| ENV["GIT_INDEX_FILE"] = index_path | |
| run_git("checkout-index", "-f", "--", changed_files...) | |
| finally | |
| if old_index === nothing | |
| delete!(ENV, "GIT_INDEX_FILE") | |
| else | |
| ENV["GIT_INDEX_FILE"] = old_index | |
| end | |
| isfile(index_path) && rm(index_path) | |
| end | |
| end | |
| return changed_files | |
| end | |
| function print_help() | |
| println("usage: $USAGE") | |
| println(DESC) | |
| println("options:") | |
| println(" --julia PATH path to Julia (default: julia)") | |
| println(" --project PROJECT Julia project Runic is installed in (default: @runic)") | |
| println(" --commit COMMIT default commit to use if none is specified (default: HEAD)") | |
| println(" --diff print a diff instead of applying the changes") | |
| println(" --diffstat print a diffstat instead of applying the changes") | |
| println(" --extensions EXTS comma-separated list of file extensions to format (default: jl)") | |
| println(" -f, --force allow changes to unstaged files") | |
| println(" -p, --patch select hunks interactively") | |
| println(" -q, --quiet print less information") | |
| println(" --staged, --cached format lines in the stage instead of the working dir") | |
| println(" -v, --verbose print extra information") | |
| println(" --diff_from_common_commit diff from the last common commit") | |
| println(" -h, --help show this help message") | |
| end | |
| function parse_args(argv) | |
| # Separate args before and after "--" | |
| dash_idx = findfirst(==("--"), argv) | |
| if dash_idx !== nothing | |
| before_dash = argv[1:dash_idx-1] | |
| dash_dash = argv[dash_idx:end] | |
| else | |
| before_dash = copy(argv) | |
| dash_dash = String[] | |
| end | |
| config = load_git_config() | |
| julia_cmd = get(config, "runic.julia", "julia") | |
| project = get(config, "runic.project", "@runic") | |
| default_commit = get(config, "runic.commit", "HEAD") | |
| diff = false | |
| diffstat = false | |
| extensions = get(config, "runic.extensions", "jl") | |
| force = false | |
| patch = false | |
| verbose = 0 | |
| staged = false | |
| diff_from_common_commit = false | |
| positional = String[] | |
| i = 1 | |
| while i <= length(before_dash) | |
| arg = before_dash[i] | |
| if arg == "--julia" && i < length(before_dash) | |
| i += 1 | |
| julia_cmd = before_dash[i] | |
| elseif startswith(arg, "--julia=") | |
| julia_cmd = arg[length("--julia=")+1:end] | |
| elseif arg == "--project" && i < length(before_dash) | |
| i += 1 | |
| project = before_dash[i] | |
| elseif startswith(arg, "--project=") | |
| project = arg[length("--project=")+1:end] | |
| elseif arg == "--commit" && i < length(before_dash) | |
| i += 1 | |
| default_commit = before_dash[i] | |
| elseif startswith(arg, "--commit=") | |
| default_commit = arg[length("--commit=")+1:end] | |
| elseif arg == "--diff" | |
| diff = true | |
| elseif arg == "--diffstat" | |
| diffstat = true | |
| elseif arg == "--extensions" && i < length(before_dash) | |
| i += 1 | |
| extensions = before_dash[i] | |
| elseif startswith(arg, "--extensions=") | |
| extensions = arg[length("--extensions=")+1:end] | |
| elseif arg ∈ ("-f", "--force") | |
| force = true | |
| elseif arg ∈ ("-p", "--patch") | |
| patch = true | |
| elseif arg ∈ ("-q", "--quiet") | |
| verbose -= 1 | |
| elseif arg ∈ ("--staged", "--cached") | |
| staged = true | |
| elseif arg ∈ ("-v", "--verbose") | |
| verbose += 1 | |
| elseif arg == "--diff_from_common_commit" | |
| diff_from_common_commit = true | |
| elseif arg ∈ ("-h", "--help") | |
| print_help() | |
| exit(0) | |
| elseif startswith(arg, "-") | |
| die("unknown option: $arg") | |
| else | |
| push!(positional, arg) | |
| end | |
| i += 1 | |
| end | |
| return (; | |
| julia_cmd, project, default_commit, diff, diffstat, | |
| extensions, force, patch, verbose, staged, | |
| diff_from_common_commit, positional, dash_dash, | |
| ) | |
| end | |
| function main(argv = ARGS) | |
| opts = parse_args(collect(argv)) | |
| commits, files = interpret_args(opts.positional, opts.dash_dash, opts.default_commit) | |
| if length(commits) > 2 | |
| die("at most two commits allowed; $(length(commits)) given") | |
| end | |
| if length(commits) == 2 | |
| if opts.staged | |
| die("--staged is not allowed when two commits are given") | |
| end | |
| if !opts.diff | |
| die("--diff is required when two commits are given") | |
| end | |
| elseif opts.diff_from_common_commit | |
| die("--diff_from_common_commit is only allowed when two commits are given") | |
| end | |
| julia_cmd = opts.julia_cmd | |
| if !isempty(dirname(julia_cmd)) | |
| julia_cmd = abspath(julia_cmd) | |
| end | |
| changed_lines = compute_diff_and_extract_lines(commits, files, opts.staged, | |
| opts.diff_from_common_commit) | |
| if opts.verbose >= 1 | |
| ignored_files = Set(keys(changed_lines)) | |
| end | |
| filter_by_extension!(changed_lines, split(lowercase(opts.extensions), ',')) | |
| cd_to_toplevel() | |
| filter_symlinks!(changed_lines) | |
| if opts.verbose >= 1 | |
| setdiff!(ignored_files, keys(changed_lines)) | |
| if !isempty(ignored_files) | |
| println("Ignoring the following files (wrong extension, symlink, or ignored by runic):") | |
| for filename in ignored_files | |
| println(" $filename") | |
| end | |
| end | |
| if !isempty(changed_lines) | |
| println("Running runic on the following files:") | |
| for filename in keys(changed_lines) | |
| println(" $filename") | |
| end | |
| end | |
| end | |
| if isempty(changed_lines) | |
| if opts.verbose >= 0 | |
| println("no modified files to format") | |
| end | |
| return 0 | |
| end | |
| if length(commits) > 1 | |
| old_tree = commits[2] # Note: Python used commits[1] (0-indexed) | |
| revision = old_tree | |
| elseif opts.staged | |
| old_tree = create_tree_from_index(keys(changed_lines)) | |
| revision = "" | |
| else | |
| old_tree = create_tree_from_workdir(keys(changed_lines)) | |
| revision = nothing | |
| end | |
| new_tree = run_runic_and_save_to_tree(changed_lines; | |
| revision = revision, | |
| julia_cmd = julia_cmd, | |
| project = opts.project) | |
| if opts.verbose >= 1 | |
| println("old tree: $old_tree") | |
| println("new tree: $new_tree") | |
| end | |
| if old_tree == new_tree | |
| if opts.verbose >= 0 | |
| println("runic did not modify any files") | |
| end | |
| return 0 | |
| end | |
| if opts.diff | |
| return print_diff(old_tree, new_tree) | |
| end | |
| if opts.diffstat | |
| return print_diffstat(old_tree, new_tree) | |
| end | |
| changed_files = apply_changes(old_tree, new_tree; | |
| force = opts.force, patch_mode = opts.patch) | |
| if (opts.verbose >= 0 && !opts.patch) || opts.verbose >= 1 | |
| println("changed files:") | |
| for filename in changed_files | |
| println(" $filename") | |
| end | |
| end | |
| return 1 | |
| end | |
| exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment