Skip to content

Instantly share code, notes, and snippets.

@KristofferC
Created April 14, 2026 15:30
Show Gist options
  • Select an option

  • Save KristofferC/1e3e05fc603da67313257a8da5d938a7 to your computer and use it in GitHub Desktop.

Select an option

Save KristofferC/1e3e05fc603da67313257a8da5d938a7 to your computer and use it in GitHub Desktop.
#!/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