#!/usr/bin/awk --exec # # Grawkit — The Awksome Git Graph Generator. # ========================================== # # Grawkit is a tool that helps build SVG graphs from Git command-line descriptions. # # This documentation is built using Markdown syntax, and can be parsed out by running `make doc` in # the project root. Please check the project's README file for additional information. # # Built-in Functions # ------------------ # # This section contains global helper functions, used across different rules, as defined in the next # section below. # > Function `add_branch` adds a new, empty branch to the internal list of branches to render. function add_branch(name) { branches[len["branches"],"name"] = name branches[len["branches"],"refs"] = "" branches[len["branches"],"tags"] = "" # Branches created before the first commit is established extend to the beginning of time. if (state["HEAD"] != "") { branches[len["branches"],"merges"] = state["branch"] "||" state["HEAD"] } else { branches[len["branches"],"merges"] = len["branches"] "||0" } len["branches"] += 1 } # > Function `add_commit` adds a new commit, with a specific type and message, to the internal list # > of commits to render. function add_commit(type, msg) { # Add commit information. commits[len["commits"],"type"] = type commits[len["commits"],"message"] = msg # Update commit references. if (branches[state["branch"],"refs"] == "") { branches[state["branch"],"refs"] = len["commits"] } else { branches[state["branch"],"refs"] = branches[state["branch"],"refs"] "," len["commits"] } state["HEAD"] = len["commits"] len["commits"] += 1 } # > Function `render_branch` renders pre-defined branch under a specific name to its SVG representation. function render_branch(idx, _, buf, tmp, refs, tags, t, i, n, hspc, vspc) { # Do not render branch with no commits. if (branches[idx,"refs"] == "") { return } # Get commit refs. n = split(branches[idx,"refs"], refs, ",") hspc = idx * style["branch/spacing"] # Print branch root element. buf = "\n" sprintf(svg["g"], "branch-" normalize(branches[idx,"name"])) # Add path for branch. tmp = "M" hspc "," refs[1] * style["commit/spacing"] tmp = tmp " L" hspc "," refs[count(refs)] * style["commit/spacing"] # Print path. buf = buf "\n\t" sprintf(svg["path"], tmp) # Add commits on path. for (i = 1; i <= n; i++) { vspc = refs[i] * style["commit/spacing"] tmp = sprintf(svg["circle"], hspc, vspc, style["commit/radius"]) buf = buf "\n\t" tmp } # Add branch tags as labels. n = split(branches[idx,"tags"], tags, ",") for (i = 1; i <= n; i++) { split(tags[i], t, "|") buf = buf render_label(t[1], t[2], t[3]) } # Add branch name as label on last commit. buf = buf render_label(refs[count(refs)], "branch", branches[idx,"name"]) return buf "\n" svg["/g"] } # > Function `render_label` adds a sidebar label at commit index, with a specific # > class and label name. Multiple labels for the same index will be placed # > side-by-side. function render_label(idx, class, name, _, buf, tw, w, h, hspc, vspc) { # Set specific length for text. Pitch size is approximately right for most fixed-width fonts, # which are usually twice as tall as they are wide, but may vary for other fonts. tw = (style["label/font-size"] * 0.5) * length(name) # Calculate width and height for label rectangle. w = tw + style["label/spacing"] h = style["label/font-size"] + style["label/spacing"] # Calculate label offsets. hspc = (len["branches"] * style["branch/spacing"]) + label_offset[idx] vspc = idx * style["commit/spacing"] # Store width of labels in relation to their commit index. label_offset[idx] += w + style["label/spacing"] # Draw label elements. buf = buf "\n\t" sprintf(svg["gg"], "label-" class, hspc, vspc) buf = buf "\n\t\t" sprintf(svg["rect"], 0, style["label/font-size"] * -1, w, h, style["label/round"]) buf = buf "\n\t\t" sprintf(svg["text"], style["label/spacing"] / 2, style["label/spacing"] / 4, tw, name) buf = buf "\n\t" svg["/g"] return buf } # > Function `render_merge` renders merge paths for a branch pointed to by `idx`. function render_merge(idx, _, buf, tmp, refs, fields, m, i, hspc, last) { # Do not render merge paths for branch with no commits. if (branches[idx,"refs"] == "") { return } # Get commit refs. split(branches[idx,"refs"], refs, ",") hspc = idx * style["branch/spacing"] # Print branch root element. buf = "\n\t" sprintf(svg["g"], normalize("branch-" branches[idx,"name"])) # Add merge paths for branch, if any. n = split(branches[idx,"merges"], fields, ",") for (i = 1; i <= n; i++) { split(fields[i], m, "|") # Add starting point to target branch ending point. tmp = "M" m[1] * style["branch/spacing"] "," m[3] * style["commit/spacing"] # Add Bezier curve leading to specific commit from source branch. tmp = tmp " C" hspc "," m[3] * style["commit/spacing"] tmp = tmp " " hspc "," m[3] * style["commit/spacing"] # Set default starting point for merge to beginning of branch, if none is set. tmp = tmp " " hspc "," ((m[2] == "") ? refs[1] : m[2]) * style["commit/spacing"] # Draw the path. buf = buf "\n\t\t" sprintf(svg["path"], tmp) # Store the commit reference for the source commit of the last merge. last = m[2] } # Extend branch to the end of time, if the last commit reference wasn't a merge source commit. if (refs[count(refs)] != last) { tmp = "M" hspc "," refs[count(refs)] * style["commit/spacing"] tmp = tmp " L" hspc "," (state["HEAD"] + 1) * style["commit/spacing"] buf = buf "\n\t\t" sprintf(svg["path"], tmp) } return buf "\n\t" svg["/g"] } # > Function `parse_command` reads the given string, and sets the global `command` array variable to # > its decomposed parts. For example, given the following command-line: # > # > git checkout -b some-branch other-branch # > # > The `command` would have the following fields set: # > # > 'command': 'git checkout' # > 'argument,b': 'some-branch' # > 'target': 'other-branch' # > # > It is assumed that downstream callers have knowledge of argument keys of interest, and can fetch # > their values using direct references. # > Target values can be placed anywhere in the command string; however, these should generally appear # > after any command-line arguments, and will be preferred in that position if any ambiguities arise. # > As with command-line arguments, these target values may not appear in command strings, and callers # > should check for their existence. function parse_command(str, _, i, m) { # Clear existing command array. split("", command) # Match base command. match(str, rule["command"]) if (RLENGTH == -1) { return } command["command"] = trim(substr(str, RSTART, RLENGTH)) str = substr(str, RSTART + RLENGTH) match(str, rule["argument"]) # Match any command-line arguments, either long or short, with additional data or not. while (RLENGTH != -1) { m = substr(str, RSTART, RLENGTH) match(m, rule["argument"]) i = (index(m, " ") > 0) ? index(m, " ") : index(m, "="); if (i > 0) { command["argument",trim(substr(m, 0, i), "- ")] = trim(substr(m, i), "\"' ") } else { command["argument",trim(m, "- ")] = "" } str = substr(str, 0, RSTART - 1) substr(str, RSTART + RLENGTH) match(str, rule["argument"]) } # Match target, if any. match(str, rule["target"]) if (RLENGTH != -1) { command["target"] = trim(substr(str, RSTART, + RLENGTH)) } } # > Function `parse_arguments` implements basic command-line parsing, based on pre-existing defaults # in the global `config` array. Processing will cease as soon an unknown command-line argument is # encountered, in order to prevent incorrect handling of filenames function parse_arguments(argc, argv, _, arg, i, option) { for (i = 1; i < argc; i++) { # Stop reading at first non-command-line option. if (substr(argv[i], 0, 2) != "--") { return } arg = ltrim(argv[i], "-") if (arg == "help") { printf "Usage: grawkit [OPTION]... [FILE]\nOptions:\n" for (k in config) { printf " --%s=\"%s\"\n \t%s\n", k, config[k], comment[k] } exit exit_code = 0 } else { # Check if command-line option given corresponds to a configuration option, or stop # handling any more options. if (split(arg, option, "=") == 2 && option[1] in config) { config[option[1]] = option[2] } else { return } } delete argv[i] } } # > Function `normalize` removes invalid characters and makes string lower-case. function normalize(str) { gsub("[/_. ]", "-", str) return tolower(str) } # > Function `count` returns the number of elements in the given array. function count(arr, _, i) { for (_ in arr) {i++} return i } # > Function `trim` removes characters (whitespace by default) off both ends of the string passed, # > and returns the modified string. function trim(str, ch) { return ltrim(rtrim(str, ch), ch) } # > Function `ltrim` removes characters (whitespace by default) off the left end of the string # > passed, and returns the modified string. function ltrim(str, ch) { ch = (ch != "") ? ch : " " gsub("^[" ch "]+", "", str) return str } # > Function `rtrim` removes characters (whitespace by default) off the right end of the string # > passed, and returns the modified string. function rtrim(str, ch) { ch = (ch != "") ? ch : " " gsub("[" ch "]+$", "", str) return str } # Global Declarations # ------------------- # # This block contains logic for initializing global variables used across Grawkit. BEGIN { # Default configuration and command-line argument parsing. config["default-branch"] = "master" comment["default-branch"] = "The name of the pre-defined base branch." config["branch-spacing"] = "50" comment["branch-spacing"] = "The amount of space, in pixels, between branch tracks." config["branch-fill"] = "none" comment["branch-fill"] = "The color to fill branch tracks with." config["branch-stroke-width"] = "10" comment["branch-stroke-width"] = "The stroke width for branch track outlines." config["commit-spacing"] = "50" comment["commit-spacing"] = "The amount of space, in pixels, between commit stops." config["commit-fill"] = "#fff" comment["commit-fill"] = "The color to fill commit stops with." config["label-spacing"] = "10" comment["label-spacing"] = "The amount of space, in pixels, between each label." config["label-round"] = "3" comment["label-round"] = "The corner radius, in pixels, for labels." config["label-fill"] = "#333" comment["label-fill"] = "The color to fill labels with." config["label-text"] = "#fff" comment["label-text"] = "The color to use for label text." config["label-font"] = "Inconsolata, Consolas, monospace" comment["label-font"] = "The font to use for label text." config["label-font-size"] = "14" comment["label-font-size"] = "The font size to use for label text, in points." config["palette"] = "#002b36,#268bd2,#859900,#cb4b16,#2aa198,#dc322f,#d33682,#6c71c4,#b58900" comment["palette"] = "The colors to use for each branch track, in order." parse_arguments(ARGC, ARGV) # Errors. message["branch/no-name"] = "Empty name for `git branch`, line %d" message["branch/no-branch"] = "No branch with name '%s', line %d" message["branch/duplicate"] = "Unable to create duplicate branch '%s', line %d" message["checkout/no-name"] = "Empty name for `git checkout`, line %d" message["merge/no-name"] = "Empty name for `git merge`, line %d" message["label/no-name"] = "Empty name for `git tag`, line %d" # Rule matching. rule["command"] = "^git[ ]+[a-z]+[ ]+" rule["target"] = "(^[^ ]+?[ ]*|([ ]*--)?[ ]+)[^ ]+[ ]*$" rule["argument/value"] = "['|\"]([^'\"]+?)['|\"]|[^-]{1,2}[^ ]+?" rule["argument"] = "^(--[a-z-](=" rule["argument/value"] ")?|-[a-z]([ ]+" rule["argument/value"] ")?)" rule["commit"] = "^git commit" rule["branch"] = "^git branch" rule["checkout"] = "^git checkout" rule["merge"] = "^git merge" rule["tag"] = "^git tag" # Style definitions. style["branch/spacing"] = config["branch-spacing"] style["branch/fill"] = config["branch-fill"] style["branch/stroke-width"] = config["branch-stroke-width"] style["commit/spacing"] = config["commit-spacing"] style["commit/fill"] = config["commit-fill"] style["commit/stroke-width"] = style["branch/stroke-width"] / 2 style["commit/radius"] = style["commit/stroke-width"] * 1.5 style["label/spacing"] = config["label-spacing"] style["label/round"] = config["label-round"] style["label/fill"] = config["label-fill"] style["label/text"] = config["label-text"] style["label/font"] = config["label-font"] style["label/font-size"] = config["label-font-size"] # Color scheme, based on `base16-solarized-dark` style["palette"] = config["palette"] # Static SVG templates. svg["svg"] = "" svg["/svg"] = "" svg["g"] = "" svg["gg"] = "" svg["/g"] = "" svg["path"] = "" svg["circle"] = "" svg["rect"] = "" svg["text"] = "%s" # Branch definitions. branches[0,"name"] = config["default-branch"] branches[0,"refs"] = "" branches[0,"merges"] = "0||0" branches[0,"tags"] = "" len["branches"] = 1 # Commit definitions. commits[0,"type"] = "" commits[0,"message"] = "" len["commits"] = 1 # Tracks the state across calls. state["branch"] = 0 state["HEAD"] = "" # Other global variables. command[""] = "" exit_code = -1 error = "" } # Rule Definitions # ---------------- # # This block contains definitions for line manipulation rules used across Grawkit. Rules may or may # not be exclusive, i.e. the effects of one rule may cascade to subsequent rules for the same line. # > Match `git commit` declarations. $0 ~ rule["commit"] { # Parse command into normalized representation. parse_command($0) # Add new commit. msg = command["argument","message"] ? command["argument","message"] : command["argument","m"] if (msg != "") { add_commit("commit", msg) } else { add_commit("commit", "Empty message") } next } # > Match `git branch` declarations. $0 ~ rule["branch"] { # Parse command into normalized representation. parse_command($0) # Get branch name and throw error if one is not set. if (command["target"] == "") { error = sprintf(message["branch/no-name"], FNR) exit } # Throw error if branch already exists. for (i = 0; i < len["branches"]; i++) { if (branches[i,"name"] == command["target"]) { error = sprintf(message["branch/duplicate"], command["target"], FNR) exit } } # Add empty branch as a placeholder. add_branch(command["target"]) next } # > Match `git checkout` declarations. $0 ~ rule["checkout"] { # Parse command into normalized representation. parse_command($0) # Get branch name and throw error if one is not set. if (command["target"] == "") { error = sprintf(message["checkout/no-name"], FNR) exit } # Throw error if branch does not exist. found = 0 for (i = 0; i < len["branches"]; i++) { if (branches[i,"name"] == command["target"]) { found = 1 break } } if (found == 0) { error = sprintf(message["branch/no-branch"], command["target"], FNR) exit } # Set internal state. state["branch"] = i split(branches[i,"refs"], refs, ",") state["HEAD"] = refs[count(refs)] next } # > Match `git merge` declarations. $0 ~ rule["merge"] { # Parse command into normalized representation. parse_command($0) # Get branch name and throw error if one is not set. if (command["target"] == "") { error = sprintf(message["merge/no-name"], FNR) exit } # Throw error if branch does not exist. found = 0 for (i = 0; i < len["branches"]; i++) { if (branches[i,"name"] == command["target"]) { found = 1 break } } if (found == 0) { error = sprintf(message["branch/no-branch"], command["target"], FNR) exit } # Add a merge commit to current branch. add_commit("merge", "Merge commit") # Add merge reference from last commit in source branch to target branch. # Format: to-branch|from-commit|to-commit split(branches[i,"refs"], refs, ",") merge = state["branch"] "|" refs[count(refs)] "|" state["HEAD"] if (branches[i,"merges"] == "") { branches[i,"merges"] = merge } else { branches[i,"merges"] = branches[i,"merges"] "," merge } next } # > Match `git tag` declarations. $0 ~ rule["tag"] { # Parse command into normalized representation. parse_command($0) # Get tag name and throw error if one is not set. if (command["target"] == "") { error = sprintf(message["label/no-name"], FNR) exit } # Add tag reference to target branch. if (branches[state["branch"],"tags"] == "") { branches[state["branch"],"tags"] = state["HEAD"] "|tag|" command["target"] } else { branches[state["branch"],"tags"] = branches[state["branch"],"tags"] "," state["HEAD"] "|tag|" command["target"] } next } # SVG Graph Generation # -------------------- # # This block contains logic for building the final SVG output from Grawkit's internal state, as # defined in the command-line provided. END { # Handle any early exit or error that might've occurred during parsing. if (exit_code > -1) { exit exit_code } else if (error != "") { print error | "cat >&2" exit (exit_code > -1) ? exit_code : 1 } w = 0 body = "" # Print merge paths for branches. for (i = len["branches"] - 1; i >= 0; i--) { body = body render_merge(i) } if (body != "") { body = sprintf(svg["g"], "merge") body body = body "\n" svg["/g"] } # Print each branch and corresponding commits in turn. for (i = len["branches"] - 1; i >= 0; i--) { body = body render_branch(i) } # Calculate SVG canvas size, removing `master` branch from X offset if it contains no commits. x = style["branch/stroke-width"] * -1 x += (branches[0,"refs"] == "") ? style["branch/spacing"] : 0 y = style["branch/stroke-width"] * -1 # Calculate canvas width from largest combined label offset. for (i in label_offset) { w = (label_offset[i] > w) ? label_offset[i] : w; } w += style["branch/spacing"] * len["branches"] w -= (branches[0,"refs"] == "") ? style["branch/spacing"] : 0 h = (style["commit/spacing"] * (len["commits"])) + (style["commit/stroke-width"] * 4) # Print SVG header. printf svg["svg"], x, y, w, h printf "\n" # Print inline style definitions. print "" # Print SVG body. print body # Print SVG footer. print svg["/svg"] }