commit 65149c39b9bca42c9380ae6fb968c87fe53bea37
Author: Robert Russell <robert@rr3.xyz>
Date: Sun, 26 Jul 2026 16:31:53 -0700
Initial commit
Diffstat:
12 files changed, 290 insertions(+), 0 deletions(-)
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026, Robert Russell
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/README b/README
@@ -0,0 +1,11 @@
+Graft: a simple "package manager"
+
+Graft does not handle remote fetching, build steps, versions, dependencies,
+package removal, or most other features you would expect from a "package
+manager" (hence the scare quotes). Graft merely links or copies
+files/directories into place and then prints what happened.
+
+You specify package installation steps in a `.graft.sh` script at the top-level
+of the package. See `example-pkg`.
+
+Graft is mainly intended for configuration file ("dotfile") management.
diff --git a/example-pkg/.graft.sh b/example-pkg/.graft.sh
@@ -0,0 +1,20 @@
+# Print messages during install:
+msg_note "This is an important note (e.g., make sure you manually do this thing)"
+msg_info "This is information"
+msg_warning "This is a warning"
+msg_error "This is an error (not fatal)"
+# msg_fatal "This is an error (fatal)"
+
+# Link files/directories:
+link foobar/foo.txt foo.txt
+link a-link a
+for f in b/*; do
+ link b-links/"$(basename "$f")" "$f"
+done
+
+# Copy files/directories:
+copy foobar/bar.txt bar.txt
+copy a-copy a
+for f in b/*; do
+ copy b-copies/"$(basename "$f")" "$f"
+done
diff --git a/example-pkg/a/0.txt b/example-pkg/a/0.txt
@@ -0,0 +1 @@
+a/0.txt
diff --git a/example-pkg/a/1.txt b/example-pkg/a/1.txt
@@ -0,0 +1 @@
+a/1.txt
diff --git a/example-pkg/a/2.txt b/example-pkg/a/2.txt
@@ -0,0 +1 @@
+a/2.txt
diff --git a/example-pkg/b/0.txt b/example-pkg/b/0.txt
@@ -0,0 +1 @@
+b/0.txt
diff --git a/example-pkg/b/1.txt b/example-pkg/b/1.txt
@@ -0,0 +1 @@
+b/1.txt
diff --git a/example-pkg/b/2.txt b/example-pkg/b/2.txt
@@ -0,0 +1 @@
+b/2.txt
diff --git a/example-pkg/bar.txt b/example-pkg/bar.txt
@@ -0,0 +1 @@
+bar.txt
diff --git a/example-pkg/foo.txt b/example-pkg/foo.txt
@@ -0,0 +1 @@
+foo.txt
diff --git a/graft b/graft
@@ -0,0 +1,236 @@
+#!/bin/bash
+set -euo pipefail
+
+USAGE="$(
+cat <<EOF
+Usage: $0 [-n] ROOT PKGS...
+EOF
+)"
+readonly USAGE
+
+# ------------------------------------------------------------------------------
+# Utility
+
+echo_lines() {
+ [ $# -ge 1 ] && printf "%s\n" "$@"
+}
+
+fail() {
+ echo_lines "$@" >&2
+ exit 1
+}
+
+# Resolve a path to an absolute path without dereferencing symlinks.
+abspath() { realpath -ms "$@"; }
+
+fg_color_code() {
+ case "$1" in
+ default) echo 39 ;;
+
+ black) echo 30 ;;
+ red) echo 31 ;;
+ green) echo 32 ;;
+ yellow) echo 33 ;;
+ blue) echo 34 ;;
+ magenta) echo 35 ;;
+ cyan) echo 36 ;;
+ white) echo 37 ;;
+
+ bright-black) echo 90 ;;
+ bright-red) echo 91 ;;
+ bright-green) echo 92 ;;
+ bright-yellow) echo 93 ;;
+ bright-blue) echo 94 ;;
+ bright-magenta) echo 95 ;;
+ bright-cyan) echo 96 ;;
+ bright-white) echo 97 ;;
+ esac
+}
+
+ansi_style() {
+ if [ -n "$USE_COLOR" ]; then
+ echo -en "\e[${1}m"
+ fi
+}
+ansi_reset() { ansi_style ""; }
+
+start_color() { ansi_style "$(fg_color_code "$1")"; }
+stop_color() { ansi_style 39; }
+
+start_bold() { ansi_style 1; }
+stop_bold() { ansi_style 22; }
+
+# ------------------------------------------------------------------------------
+# Package API
+
+msg() {
+ [ $# -ge 3 ] || fail "Usage: msg COLOR LABEL LINES..."
+ local color; color="$1"
+ local label; label="$2"
+ local first; first="$3"
+ shift 3
+
+ local prefix; prefix="$(stop_color)[$NAME] $(start_color "$color")"
+ echo "$prefix$(start_bold)$label$(stop_bold)$first"
+ echo_lines "${@/#/$prefix$(printf '%*s' ${#label} '')}"
+ stop_color
+}
+
+msg_note() { msg magenta "NOTE: " "$@"; }
+msg_info() { msg default "INFO: " "$@"; }
+msg_warning() { msg yellow "WARNING: " "$@"; }
+msg_error() { msg red "ERROR: " "$@"; }
+msg_fatal() {
+ msg bright-red "FATAL: " "$@" >&2
+ exit 1
+}
+msg_bad_package() {
+ [ $# -ge 1 ] || fail "Usage: msg_bad_package REASON CONTEXT..."
+ local reason; reason="$1"
+ shift 1
+ msg_fatal "bad package: $reason" "$@"
+}
+
+link() {
+ [ $# -eq 2 ] || msg_bad_package "invalid link" "Usage: link DST SRC"
+ local rel_dst; rel_dst="$1"
+ local rel_src; rel_src="$2"
+
+ local abs_dst; abs_dst="$ROOT/$rel_dst"
+ local abs_src; abs_src="$PKG/$rel_src"
+
+ [ -e "$abs_src" ] || [ -h "$abs_src" ] || msg_bad_package "invalid link: source does not exist"
+
+ local move; move=""
+ if [ -e "$abs_dst" ] || [ -h "$abs_dst" ]; then
+ case "$(readlink -q "$abs_dst")" in
+ "$abs_src")
+ msg bright-black "LINK OK: " "$abs_dst --> $rel_src"
+ return 0
+ ;;
+ "$PKG"/*)
+ move=1
+ ;;
+ *)
+ msg red "LINK CONFLICT: " "$abs_dst -/-> $rel_src"
+ return 1
+ ;;
+ esac
+ fi
+
+ if [ -z "$DRY" ]; then
+ local err
+
+ local parent; parent="$(dirname "$abs_dst")"
+ if ! err="$(mkdir -p "$parent" 2>&1)"; then
+ msg red "LINK FAILED: " "$abs_dst -/-> $rel_src" "$err"
+ return 1
+ fi
+
+ local -a ln_opt=(); [ -n "$move" ] && ln_opt=("-f")
+ if ! err="$(ln -sn "${ln_opt[@]}" "$abs_src" "$abs_dst" 2>&1)"; then
+ msg red "LINK FAILED: " "$abs_dst -/-> $rel_src" "$err"
+ return 1
+ fi
+ fi
+
+ if [ -n "$move" ]; then
+ msg cyan "LINK MOVE: " "$abs_dst --> $rel_src"
+ else
+ msg blue "LINK CREATE: " "$abs_dst --> $rel_src"
+ fi
+}
+
+copy() {
+ [ $# -eq 2 ] || msg_bad_package "invalid copy" "Usage: copy DST SRC"
+ local rel_dst; rel_dst="$1"
+ local rel_src; rel_src="$2"
+
+ local abs_dst; abs_dst="$ROOT/$rel_dst"
+ local abs_src; abs_src="$PKG/$rel_src"
+
+ [ -e "$abs_src" ] || [ -h "$abs_src" ] || msg_bad_package "invalid copy: source does not exist"
+
+ if [ -e "$abs_dst" ] || [ -h "$abs_dst" ]; then
+ if diff -rq "$abs_dst" "$abs_src" >/dev/null 2>&1; then
+ msg bright-black "COPY OK: " "$abs_dst <-- $rel_src"
+ return 0
+ else
+ msg red "COPY CONFLICT: " "$abs_dst <-/- $rel_src"
+ return 1
+ fi
+ fi
+
+ if [ -z "$DRY" ]; then
+ local err
+
+ local parent; parent="$(dirname "$abs_dst")"
+ if ! err="$(mkdir -p "$parent" 2>&1)"; then
+ msg red "COPY FAILED: " "$abs_dst <-/- $rel_src" "$err"
+ return 1
+ fi
+
+ if ! err="$(cp -rpT "$abs_src" "$abs_dst" 2>&1)"; then
+ msg red "COPY FAILED: " "$abs_dst <-/- $rel_src" "$err"
+ return 1
+ fi
+ fi
+
+ msg green "COPY CREATE: " "$abs_dst <-- $rel_src"
+}
+
+# ------------------------------------------------------------------------------
+# Input Handling
+
+# Options
+opt_force_color=""
+opt_dry=""
+while getopts "cnh" opt; do
+ case "$opt" in
+ c) opt_force_color=1 ;;
+ n) opt_dry=1 ;;
+ h) echo "$USAGE"; exit 0 ;;
+ *) fail "$USAGE" ;;
+ esac
+done
+readonly opt_force_color
+readonly opt_dry
+shift $((OPTIND - 1))
+
+# Arguments
+[ $# -ge 1 ] || fail "$USAGE"
+arg_root="$1"; readonly arg_root
+shift 1
+arg_pkgs=("$@"); readonly arg_pkgs
+
+# ------------------------------------------------------------------------------
+# Globals
+
+DRY="$opt_dry"; readonly DRY
+ROOT="$(abspath "$arg_root")"; readonly ROOT
+
+USE_COLOR=""
+if [ -z "${NO_COLOR-}" ] && [ -t 1 ] || [ -n "$opt_force_color" ]; then
+ USE_COLOR=1
+fi
+readonly USE_COLOR
+
+# ------------------------------------------------------------------------------
+# Main
+
+graft() {
+ local NAME; NAME="$1"; readonly NAME
+ local PKG; PKG="$(abspath "$NAME")"; readonly PKG
+
+ [ -d "$PKG" ] || msg_bad_package "not found: $PKG"
+ cd "$PKG"
+ [ -r .graft.sh ] || msg_bad_package "could not read .graft.sh"
+ set +e
+ source ./.graft.sh
+ return 0
+}
+
+[ -d "$ROOT" ] || fail "root is not a directory"
+for p in "${arg_pkgs[@]}"; do
+ (graft "$p") || true # TODO: Count errors?
+done