From 56881b766aa5c0123bd11f375a8b5d85f028b5f1 Mon Sep 17 00:00:00 2001 From: zalub Date: Fri, 22 May 2026 07:45:47 +0000 Subject: [PATCH] feat: +package merge-archive --- package/default.nix | 1 + package/merge-archive/default.nix | 25 +++ package/merge-archive/merge-archive.sh | 293 +++++++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 package/merge-archive/default.nix create mode 100644 package/merge-archive/merge-archive.sh diff --git a/package/default.nix b/package/default.nix index 21c4508..c34afb7 100644 --- a/package/default.nix +++ b/package/default.nix @@ -125,6 +125,7 @@ in { py3-openai-shap-e = pkgs.callPackage ./py3-openai-shap-e.nix {}; nvim-alias = pkgs.callPackage ./nvim-alias.nix {}; bolt-unpack = pkgs.callPackage ./bolt-unpack.nix {}; + merge-archive = pkgs.callPackage ./merge-archive {}; nvim-pager = pkgs.callPackage ./nvim-pager.nix {}; colorize = pkgs.callPackage ./colorize.nix {}; github-gh-tl = pkgs.callPackage ./github/gh-tl.nix {}; diff --git a/package/merge-archive/default.nix b/package/merge-archive/default.nix new file mode 100644 index 0000000..f51971e --- /dev/null +++ b/package/merge-archive/default.nix @@ -0,0 +1,25 @@ +{ dash, hectic, git, gnutar, gzip, bzip2, xz, unzip, coreutils, file }: +let + shell = "${dash}/bin/dash"; +in +hectic.writeShellApplication { + inherit shell; + bashOptions = [ + "errexit" + "nounset" + ]; + excludeShellChecks = [ "SC2209" ]; + name = "merge-archive"; + runtimeInputs = [ git gnutar gzip bzip2 xz unzip coreutils file ]; + + text = '' + ${builtins.readFile hectic.helpers.posix-shell.log} + ${builtins.readFile hectic.helpers.posix-shell.pager_or_cat} + ${builtins.readFile ./merge-archive.sh} + ''; + + meta = { + description = "Merge an archive into a git repository with --allow-unrelated-histories"; + mainProgram = "merge-archive"; + }; +} diff --git a/package/merge-archive/merge-archive.sh b/package/merge-archive/merge-archive.sh new file mode 100644 index 0000000..cba41c7 --- /dev/null +++ b/package/merge-archive/merge-archive.sh @@ -0,0 +1,293 @@ +# shellcheck shell=dash +# shellcheck disable=SC3043 + +: "${SCRIPT_NAME:=$(basename "$0")}" +SCRIPT_NAME=${SCRIPT_NAME%%.sh} + +pager_or_cat_init + +help() { + # shellcheck disable=SC2059 + printf "$(cat < [TARGET_DIR] + +Merge an archive into a git repository using ${CYAN}--allow-unrelated-histories${NC}. + +${BGREEN}Arguments:${NC} + ${BCYAN}ARCHIVE${NC} Archive file to import + ${BCYAN}TARGET_DIR${NC} Target directory inside a git work tree (default: ${BBLACK}$PWD${NC}) + +${BGREEN}Options:${NC} + ${BCYAN}--no-strip${NC} Keep the archive root directory intact + ${BCYAN}--strip${NC} Force stripping a single top-level directory + ${BCYAN}-m${NC}, ${BCYAN}--message${NC} ${CYAN}MSG${NC} Merge commit message + ${BCYAN}-h${NC}, ${BCYAN}--help${NC} Show this help message + +${BGREEN}Formats:${NC} + ${BBLACK}.tar${NC} ${BBLACK}.tar.gz${NC} ${BBLACK}.tgz${NC} ${BBLACK}.tar.bz2${NC} ${BBLACK}.tbz2${NC} ${BBLACK}.tar.xz${NC} ${BBLACK}.txz${NC} ${BBLACK}.zip${NC} + File-magic fallback is used when the extension is missing or ambiguous. + +${BGREEN}Examples:${NC} + $SCRIPT_NAME release.tar.gz + $SCRIPT_NAME release.zip /path/to/repo + $SCRIPT_NAME --no-strip archive.tar.gz + $SCRIPT_NAME --message "Import upstream v2.0" release.tar.gz + +EOF +)" | "$PAGER_OR_CAT" +} + +detect_archive_format() { + local archive="$1" + + case "$archive" in + *.tar.gz|*.tgz) + printf '%s\n' 'tar.gz' + return 0 + ;; + *.tar.bz2|*.tbz2) + printf '%s\n' 'tar.bz2' + return 0 + ;; + *.tar.xz|*.txz) + printf '%s\n' 'tar.xz' + return 0 + ;; + *.tar) + printf '%s\n' 'tar' + return 0 + ;; + *.zip) + printf '%s\n' 'zip' + return 0 + ;; + esac + + case "$(file -b --mime-type "$archive")" in + application/zip) + printf '%s\n' 'zip' + ;; + application/x-tar) + printf '%s\n' 'tar' + ;; + application/gzip) + printf '%s\n' 'tar.gz' + ;; + application/x-bzip2) + printf '%s\n' 'tar.bz2' + ;; + application/x-xz) + printf '%s\n' 'tar.xz' + ;; + *) + log error "unsupported archive format: $archive" + log info "supported formats: .tar .tar.gz .tgz .tar.bz2 .tbz2 .tar.xz .txz .zip" + exit 9 + ;; + esac +} + +extract_archive() { + local archive="$1" + local dest="$2" + local format="$3" + + case "$format" in + tar) + tar -xf "$archive" -C "$dest" + ;; + tar.gz) + tar -xzf "$archive" -C "$dest" + ;; + tar.bz2) + tar -xjf "$archive" -C "$dest" + ;; + tar.xz) + tar -xJf "$archive" -C "$dest" + ;; + zip) + unzip -q "$archive" -d "$dest" + ;; + *) + log error "unsupported archive format: $archive" + exit 9 + ;; + esac +} + +single_top_level_dir() { + local dir="$1" + local entry_count=0 + local top_level_dir="" + local entry + + for entry in "$dir"/* "$dir"/.[!.]* "$dir"/..?*; do + [ -e "$entry" ] || continue + entry_count=$((entry_count + 1)) + if [ "$entry_count" -gt 1 ]; then + return 1 + fi + if [ -d "$entry" ]; then + top_level_dir="$entry" + else + return 1 + fi + done + + if [ "$entry_count" -eq 1 ] && [ -n "$top_level_dir" ]; then + printf '%s\n' "$top_level_dir" + return 0 + fi + + return 1 +} + +cleanup() { + if [ -n "${TARGET_REPO_ROOT:-}" ] && [ -n "${TEMP_REF:-}" ]; then + git -C "$TARGET_REPO_ROOT" update-ref -d "$TEMP_REF" >/dev/null 2>&1 || : + fi + + if [ -n "${WORK_DIR:-}" ] && [ -d "$WORK_DIR" ]; then + rm -rf "$WORK_DIR" + fi +} + +ARCHIVE="" +TARGET_DIR="$PWD" +AUTO_STRIP=1 +MERGE_MESSAGE="" + +if [ $# -eq 0 ]; then + log error "archive argument is required" + help + exit 3 +fi + +while [ $# -gt 0 ]; do + case $1 in + -h|--help) + help + exit 0 + ;; + --no-strip) + AUTO_STRIP=0 + shift + ;; + --strip) + AUTO_STRIP=1 + shift + ;; + -m|--message) + if [ $# -lt 2 ]; then + log error "--message requires an argument" + exit 3 + fi + MERGE_MESSAGE="$2" + shift 2 + ;; + --*|-*) + log error "unknown option: $1" + exit 9 + ;; + *) + if [ -z "$ARCHIVE" ]; then + ARCHIVE="$1" + elif [ "$TARGET_DIR" = "$PWD" ]; then + TARGET_DIR="$1" + else + log error "unexpected argument: $1" + exit 9 + fi + shift + ;; + esac +done + +if [ -z "$ARCHIVE" ]; then + log error "no archive specified" + help + exit 3 +fi + +if [ ! -e "$ARCHIVE" ]; then + log error "archive not found: $ARCHIVE" + exit 1 +fi + +if [ ! -d "$TARGET_DIR" ]; then + log error "target directory not found: $TARGET_DIR" + exit 1 +fi + +if ! TARGET_REPO_ROOT=$(git -C "$TARGET_DIR" rev-parse --show-toplevel 2>/dev/null); then + log error "target directory is not inside a git repository: $TARGET_DIR" + exit 1 +fi + +if ! git -C "$TARGET_REPO_ROOT" rev-parse --verify HEAD >/dev/null 2>&1; then + log error "target repository has no commits yet" + exit 1 +fi + +if [ -n "$(git -C "$TARGET_REPO_ROOT" status --porcelain --untracked-files=all)" ]; then + log error "target repository has uncommitted changes" + log warn "commit, stash, or clean the tree before merging" + exit 1 +fi + +trap cleanup EXIT INT HUP TERM + +ARCHIVE_BASENAME=${ARCHIVE##*/} +: "${MERGE_MESSAGE:=Merge archive ${ARCHIVE_BASENAME}}" + +log notice "merging ${BCYAN}${ARCHIVE_BASENAME}${NC} into ${BCYAN}${TARGET_REPO_ROOT}${NC}" + +WORK_DIR=$(mktemp -d) +EXTRACT_DIR="$WORK_DIR/extracted" +TEMP_REPO="$WORK_DIR/repo" +mkdir -p "$EXTRACT_DIR" "$TEMP_REPO" + +log info "unpacking archive" +ARCHIVE_FORMAT=$(detect_archive_format "$ARCHIVE") +extract_archive "$ARCHIVE" "$EXTRACT_DIR" "$ARCHIVE_FORMAT" + +SOURCE_DIR="$EXTRACT_DIR" +if [ "$AUTO_STRIP" -eq 1 ]; then + if STRIPPED_DIR=$(single_top_level_dir "$EXTRACT_DIR"); then + SOURCE_DIR=$STRIPPED_DIR + fi +fi + +log info "initializing temporary git repository" +git -C "$TEMP_REPO" init -q +git -C "$TEMP_REPO" config user.email "merge-archive@local" +git -C "$TEMP_REPO" config user.name "merge-archive" + +cp -R "$SOURCE_DIR"/. "$TEMP_REPO"/ + +git -C "$TEMP_REPO" add -A +git -C "$TEMP_REPO" commit -q -m "archive: $ARCHIVE_BASENAME" + +log info "fetching temporary repository" +TEMP_REF="refs/merge-archive/import/$$" +git -C "$TARGET_REPO_ROOT" fetch -q "$TEMP_REPO" HEAD:"$TEMP_REF" + +log notice "merging with --allow-unrelated-histories" +if ! git -C "$TARGET_REPO_ROOT" merge \ + --allow-unrelated-histories \ + -m "$MERGE_MESSAGE" \ + "$TEMP_REF"; then + log error "merge conflict(s) detected" + log warn "conflicted files:" + git -C "$TARGET_REPO_ROOT" diff --name-only --diff-filter=U | while IFS= read -r conflicted_file; do + [ -n "$conflicted_file" ] || continue + log warn " $conflicted_file" + done + log warn "run 'git -C $TARGET_REPO_ROOT merge --abort' to cancel the merge" + exit 1 +fi + +log notice "merge ${GREEN}complete${NC}"