graft

graft: a simple package manager
git clone git://git.rr3.xyz/graft | git clone gits://git.rr3.xyz/graft
Log | Files | Refs | README | LICENSE

commit 65149c39b9bca42c9380ae6fb968c87fe53bea37
Author: Robert Russell <robert@rr3.xyz>
Date:   Sun, 26 Jul 2026 16:31:53 -0700

Initial commit

Diffstat:
ALICENSE | 15+++++++++++++++
AREADME | 11+++++++++++
Aexample-pkg/.graft.sh | 20++++++++++++++++++++
Aexample-pkg/a/0.txt | 1+
Aexample-pkg/a/1.txt | 1+
Aexample-pkg/a/2.txt | 1+
Aexample-pkg/b/0.txt | 1+
Aexample-pkg/b/1.txt | 1+
Aexample-pkg/b/2.txt | 1+
Aexample-pkg/bar.txt | 1+
Aexample-pkg/foo.txt | 1+
Agraft | 236+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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