From 7661bf451cf43ed93369f100beef5549f29b7cb2 Mon Sep 17 00:00:00 2001 From: Mark Pitman Date: Sun, 26 Apr 2026 14:06:36 -0700 Subject: [PATCH 1/6] Add mktlapse script for timelapse video generation Convert timelapse JPEG sequences into MP4 videos using ffmpeg. Each subdirectory of the input dir is treated as a job; frames are sorted by name and encoded with libx264. Flags: -r (framerate), -o (output dir), -n (dry run), -h (help). --- Makefile | 2 + general/mktlapse | 101 ++++++++++++++++++++++++++++++++++++++++++++++ man/mktlapse.1.md | 73 +++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100755 general/mktlapse create mode 100644 man/mktlapse.1.md diff --git a/Makefile b/Makefile index 6449c66..114a4b2 100644 --- a/Makefile +++ b/Makefile @@ -8,11 +8,13 @@ MAN1DIR ?= $(MANDIR)/man1 PANDOC ?= pandoc SCRIPTS := \ + general/mktlapse \ general/selfcert \ general/strip-ext \ qemu/vmctl MAN_SOURCES := \ + man/mktlapse.1.md \ man/selfcert.1.md \ man/strip-ext.1.md \ man/vmctl.1.md diff --git a/general/mktlapse b/general/mktlapse new file mode 100755 index 0000000..62c073a --- /dev/null +++ b/general/mktlapse @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat < + +Convert timelapse JPEG sequences into MP4 videos using ffmpeg. + +Each subdirectory of is treated as a separate job. JPEGs +within each job directory (including subdirectories) are sorted by name and +assembled into a video named after the job. + +Options: + -r framerate Frames per second for the output video (default: 24) + -o output_dir Directory to write videos into (default: /../timelapse_videos) + -n Dry run: show what would be processed without creating videos + -h Show this help message + +Examples: + $(basename "$0") ~/timelapse + $(basename "$0") -r 30 ~/timelapse + $(basename "$0") -o ~/videos ~/timelapse + $(basename "$0") -n ~/timelapse +EOF +} + +framerate=24 +output_dir="" +dry_run=false + +while getopts ":r:o:nh" opt; do + case $opt in + r) framerate="$OPTARG" ;; + o) output_dir="$OPTARG" ;; + n) dry_run=true ;; + h) usage; exit 0 ;; + :) echo "Error: option -$OPTARG requires an argument" >&2; usage >&2; exit 1 ;; + \?) echo "Error: unknown option: -$OPTARG" >&2; usage >&2; exit 1 ;; + esac +done +shift $((OPTIND - 1)) + +if [[ $# -ne 1 ]]; then + echo "Error: expected exactly one argument (timelapse_dir)" >&2 + usage >&2 + exit 1 +fi + +timelapse_dir="$(cd -- "$1" && pwd)" + +if [[ -z "$output_dir" ]]; then + output_dir="$(dirname "$timelapse_dir")/timelapse_videos" +fi + +command -v ffmpeg >/dev/null 2>&1 || { + echo "Error: ffmpeg is required" >&2 + exit 1 +} + +if ! $dry_run; then + mkdir -p -- "$output_dir" +fi + +for job_dir in "$timelapse_dir"/*/; do + [[ -d "$job_dir" ]] || continue + job_name="$(basename -- "$job_dir")" + output_file="$output_dir/${job_name}.mp4" + + mapfile -t jpegs < <(find "$job_dir" -name "*.jpg" -o -name "*.jpeg" | sort) + + if [[ ${#jpegs[@]} -eq 0 ]]; then + echo "Skipping '$job_name': no JPEGs found." + continue + fi + + echo "Processing '$job_name': ${#jpegs[@]} frames -> $output_file" + + if $dry_run; then + continue + fi + + tmp_list="$(mktemp /tmp/mktlapse_XXXXXX.txt)" + trap 'rm -f "$tmp_list"' EXIT + + for f in "${jpegs[@]}"; do + printf "file '%s'\n" "$f" >> "$tmp_list" + done + + ffmpeg -y -r "$framerate" -f concat -safe 0 -i "$tmp_list" \ + -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" \ + -c:v libx264 -pix_fmt yuv420p -crf 23 \ + "$output_file" \ + && echo " -> Done: $output_file" \ + || echo " -> Failed: $output_file" >&2 + + rm -f -- "$tmp_list" + trap - EXIT +done + +echo "All done. Videos in: $output_dir" diff --git a/man/mktlapse.1.md b/man/mktlapse.1.md new file mode 100644 index 0000000..87512f0 --- /dev/null +++ b/man/mktlapse.1.md @@ -0,0 +1,73 @@ +--- +title: MKTLAPSE +section: 1 +header: User Commands +date: April 2026 +author: Mark Pitman +--- + +# NAME + +mktlapse - convert timelapse JPEG sequences into MP4 videos + +# SYNOPSIS + +**mktlapse** [-n] [-r framerate] [-o output_dir] \ + +# DESCRIPTION + +**mktlapse** scans each subdirectory of *timelapse_dir* as a separate job, +collects all JPEG files within it (sorted by name for chronological order), and +encodes them into an H.264 MP4 video using **ffmpeg**. + +Each job produces a single video file named after the job directory. Jobs +containing no JPEG files are skipped with a warning. + +Output videos are written to *output_dir*. If **-o** is not given, the default +is a **timelapse_videos** directory alongside *timelapse_dir*. + +# OPTIONS + +**-r** framerate + +: Frames per second for the output video. Defaults to **24**. + +**-o** output_dir + +: Directory to write the generated MP4 files into. Created if it does not + exist. Defaults to *\/../timelapse_videos*. + +**-n** + +: Dry run. Print what would be processed without creating any files. + +**-h** + +: Show usage help and exit. + +# EXAMPLES + +```bash +mktlapse ~/timelapse +mktlapse -r 30 ~/timelapse +mktlapse -o ~/videos ~/timelapse +mktlapse -n ~/timelapse +``` + +# EXIT STATUS + +**0** + +: All jobs completed (some may have been skipped). + +**1** + +: Invalid usage or a required dependency is missing. + +# DEPENDENCIES + +**ffmpeg**(1) must be installed and available on **$PATH**. + +# SEE ALSO + +**ffmpeg**(1) From 10ee71c4811665e1004e9b675c3e0b42c739c0b4 Mon Sep 17 00:00:00 2001 From: Mark Pitman Date: Thu, 25 Jun 2026 19:42:51 -0700 Subject: [PATCH 2/6] feat: add nmapscan script, manpage, and gitignore results directory --- .gitignore | 1 + Makefile | 2 + general/nmapscan | 154 ++++++++++++++++++++++++++++++++++++++++++++++ man/nmapscan.1.md | 106 +++++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+) create mode 100755 general/nmapscan create mode 100644 man/nmapscan.1.md diff --git a/.gitignore b/.gitignore index 567609b..9a2bfe8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ build/ +nmapscan-results/ diff --git a/Makefile b/Makefile index 114a4b2..9ba31c2 100644 --- a/Makefile +++ b/Makefile @@ -9,12 +9,14 @@ PANDOC ?= pandoc SCRIPTS := \ general/mktlapse \ + general/nmapscan \ general/selfcert \ general/strip-ext \ qemu/vmctl MAN_SOURCES := \ man/mktlapse.1.md \ + man/nmapscan.1.md \ man/selfcert.1.md \ man/strip-ext.1.md \ man/vmctl.1.md diff --git a/general/nmapscan b/general/nmapscan new file mode 100755 index 0000000..ae91692 --- /dev/null +++ b/general/nmapscan @@ -0,0 +1,154 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat < + +Run a comprehensive nmap reconnaissance scan against an IP address, hostname, +or CIDR subnet. Performs host discovery, full TCP port scan, service/version +and OS detection, UDP scan (requires root), NSE script battery, and a +traceroute probe. + +Options: + -o dir Directory for output files (default: ./nmapscan-results) + -u Skip UDP scan even when running as root + -h Show this help message + +Arguments: + target IP address, hostname, or CIDR subnet (e.g. 192.168.1.0/24) + +Examples: + $(basename "$0") 192.168.1.1 + $(basename "$0") 192.168.1.0/24 + $(basename "$0") -o /tmp/scans 10.0.0.0/16 + $(basename "$0") -u scanme.nmap.org +EOF +} + +outdir="./nmapscan-results" +skip_udp=false + +while getopts ":o:uh" opt; do + case "$opt" in + o) outdir="$OPTARG" ;; + u) skip_udp=true ;; + h) usage; exit 0 ;; + :) echo "Error: option -$OPTARG requires an argument" >&2; usage >&2; exit 1 ;; + \?) echo "Error: unknown option -$OPTARG" >&2; usage >&2; exit 1 ;; + esac +done + +shift $((OPTIND - 1)) + +if [[ $# -ne 1 ]]; then + echo "Error: exactly one target is required" >&2 + usage >&2 + exit 1 +fi + +TARGET="$1" + +command -v nmap >/dev/null 2>&1 || { echo "Error: nmap is required" >&2; exit 1; } + +# Several nmap features (ICMP probes, UDP ping, OS detection, raw SYN scans, +# UDP scan) require reading raw packets off the wire, which needs root. Rather +# than requiring the whole script to run as root, we prefix nmap with sudo so +# you keep your normal environment while nmap itself gets the privileges it needs. +if [[ $EUID -eq 0 ]]; then + NMAP="nmap" +else + if ! command -v sudo >/dev/null 2>&1; then + echo "Error: sudo is required to run nmap with the privileges it needs" >&2 + exit 1 + fi + echo "[!] Some nmap features (ICMP probes, OS detection, raw SYN/UDP scans)" + echo " require root. The script will invoke nmap via sudo where needed." + echo " You may be prompted for your password." + echo "" + NMAP="sudo nmap" +fi + +mkdir -p "$outdir" +TS=$(date +%Y%m%d_%H%M%S) +# Sanitize target for use in filenames (replace dots, slashes, colons) +SAFE="${TARGET//\//_}" +SAFE="${SAFE//./_}" +BASE="${outdir}/${SAFE}_${TS}" + +echo "[*] Target: $TARGET" +echo "[*] Output dir: $outdir" +echo "" + +# ── 1. Host discovery ───────────────────────────────────────────────────────── +echo "[1/6] Host discovery..." +$NMAP -sn \ + -PE -PP -PM \ + -PS22,80,443,8080,8443 \ + -PA80,443 \ + -PU53,161 \ + --traceroute \ + -oN "${BASE}_01_discovery.txt" \ + -- "$TARGET" + +# ── 2. Full TCP port scan ───────────────────────────────────────────────────── +echo "[2/6] Full TCP port scan (all 65535 ports)..." +$NMAP -p- -T4 --min-rate 1000 --open \ + -oN "${BASE}_02_tcp_full.txt" \ + -- "$TARGET" + +OPEN_PORTS=$(grep "^[0-9]" "${BASE}_02_tcp_full.txt" 2>/dev/null \ + | grep open \ + | awk -F'/' '{print $1}' \ + | paste -sd',' || true) + +echo " Open TCP ports: ${OPEN_PORTS:-none found}" + +PORT_ARG="${OPEN_PORTS:-1-1000}" + +# ── 3. Service & version detection + OS fingerprinting ──────────────────────── +echo "[3/6] Service/version detection and OS fingerprinting..." +$NMAP -p "$PORT_ARG" \ + -sV --version-intensity 9 \ + -O --osscan-guess \ + -sC \ + -oN "${BASE}_03_services.txt" \ + -- "$TARGET" + +# ── 4. UDP scan (top 200 common ports) ──────────────────────────────────────── +echo "[4/6] UDP scan (top 200 ports)..." +if [[ "$skip_udp" == true ]]; then + echo " Skipped (-u flag)" +else + $NMAP -sU --top-ports 200 -T4 --open \ + -oN "${BASE}_04_udp.txt" \ + -- "$TARGET" +fi + +# ── 5. NSE script battery ───────────────────────────────────────────────────── +echo "[5/6] NSE script battery (default, auth, discovery, vuln, malware)..." +$NMAP -p "$PORT_ARG" \ + --script "default,auth,discovery,vuln,malware,safe" \ + --script-timeout 30s \ + -oN "${BASE}_05_scripts.txt" \ + -- "$TARGET" + +# ── 6. Traceroute + evasion timing probe ───────────────────────────────────── +echo "[6/6] Traceroute and low-noise timing probe..." +$NMAP -p "$PORT_ARG" \ + --traceroute \ + -sV -T2 \ + -oN "${BASE}_06_traceroute.txt" \ + -- "$TARGET" + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo "" +echo "==============================" +echo " Scan complete — results saved" +echo "==============================" +ls -1 "${outdir}/"*"${TS}"* 2>/dev/null + +echo "" +echo "[*] Open services detected:" +grep "^[0-9].*open" "${BASE}_03_services.txt" 2>/dev/null || echo " (none detected)" diff --git a/man/nmapscan.1.md b/man/nmapscan.1.md new file mode 100644 index 0000000..4900d5b --- /dev/null +++ b/man/nmapscan.1.md @@ -0,0 +1,106 @@ +--- +title: NMAPSCAN +section: 1 +header: User Commands +date: May 2026 +author: Mark Pitman +--- + +# NAME + +nmapscan - comprehensive nmap reconnaissance scan against a single target + +# SYNOPSIS + +**nmapscan** [-h] [-o output_dir] [-u] \ + +# DESCRIPTION + +**nmapscan** runs a six-phase nmap reconnaissance campaign against an IP +address, hostname, or CIDR subnet and writes each phase to a timestamped file +in the output directory. + +Phases: + +1. **Host discovery** — ICMP echo/timestamp/netmask probes plus TCP SYN/ACK and UDP pings with traceroute. +2. **Full TCP port scan** — all 65535 ports at high speed to enumerate every open TCP port. +3. **Service/version and OS detection** — version intensity 9, OS fingerprinting with guess mode, and the default NSE script set on all open ports. +4. **UDP scan** — top 200 common UDP ports (requires root; skipped otherwise or with **-u**). +5. **NSE script battery** — `default`, `auth`, `discovery`, `vuln`, `malware`, and `safe` script categories against open TCP ports. +6. **Traceroute and timing probe** — low-noise `-T2` pass with traceroute to map the network path and gather additional service detail. + +A summary of open services is printed to stdout when all phases complete. + +# OPTIONS + +**-o** output_dir + +: Directory where result files are written. + Default: `./nmapscan-results`. + +**-u** + +: Skip the UDP scan even when running as root. + +**-h** + +: Show usage help and exit. + +# ARGUMENTS + +**target** + +: IP address, hostname, or CIDR subnet (e.g. `192.168.1.0/24`) to scan. + Exactly one target is required. + +# OUTPUT FILES + +Each phase writes a plain-text nmap report named: + +``` +/___.txt +``` + +| File | Contents | +|------|----------| +| `*_01_discovery.txt` | Host discovery results | +| `*_02_tcp_full.txt` | Full TCP port scan | +| `*_03_services.txt` | Service, version, and OS detection | +| `*_04_udp.txt` | UDP scan (when run) | +| `*_05_scripts.txt` | NSE script output | +| `*_06_traceroute.txt` | Traceroute and timing probe | + +# EXAMPLES + +```bash +nmapscan 192.168.1.1 +nmapscan 192.168.1.0/24 +nmapscan -o /tmp/scans 10.0.0.0/16 +nmapscan -u scanme.nmap.org +sudo nmapscan 10.10.0.1 +``` + +# EXIT STATUS + +**0** + +: All phases completed successfully. + +**1** + +: Invalid arguments, missing **nmap** dependency, or scan failure. + +# NOTES + +Root privileges (or `sudo`) are required for the UDP scan phase and for raw-socket +OS fingerprinting. Without root, both are skipped automatically. + +Aggressive NSE categories such as `exploit`, `brute`, and `intrusive` are +intentionally omitted to avoid unintended service disruption. Add +`--script exploit,brute` manually if intrusive testing is desired. + +Only scan hosts you own or have explicit written permission to test. + +# SEE ALSO + +**nmap**(1) From a71084ca8c0eafde4b04e863fa1d348a069a5c93 Mon Sep 17 00:00:00 2001 From: Mark Pitman Date: Thu, 25 Jun 2026 21:46:06 -0700 Subject: [PATCH 3/6] Convert build system from Makefile to justfile --- AGENTS.md | 6 ++-- Makefile | 78 ----------------------------------------------- justfile | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 81 deletions(-) delete mode 100644 Makefile create mode 100644 justfile diff --git a/AGENTS.md b/AGENTS.md index fd599e0..40a1eb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,8 @@ Shell utility scripts installed to `~/.local/bin` so they're on `$PATH` from any ## Install And Man Pages -- **Makefile is the source of truth** — every installed script must be listed in `SCRIPTS` in the `Makefile` -- **Install and uninstall stay in sync** — `make install` and `make uninstall` should handle both scripts and their man pages together +- **justfile is the source of truth** — every installed script must be listed in `scripts` in the `justfile` +- **Install and uninstall stay in sync** — `just install` and `just uninstall` should handle both scripts and their man pages together - **Man source naming** — each installed command should have a matching manpage source named `man/.1.md` - **Generated manpages are build artifacts** — the Markdown sources under `man/` are the canonical source, and `pandoc` generates the `man(1)` files - **User-local install paths** — scripts install to `~/.local/bin`; man pages install under `${XDG_DATA_HOME:-$HOME/.local/share}/man/man1` @@ -32,7 +32,7 @@ Shell utility scripts installed to `~/.local/bin` so they're on `$PATH` from any ## Naming - **Prefer short command names** — choose concise, extensionless names intended for direct use from `$PATH` -- **Renames must be propagated** — if a script is renamed, update the script path, `Makefile`, manpage source name/title, and any local tooling references +- **Renames must be propagated** — if a script is renamed, update the script path, `justfile`, manpage source name/title, and any local tooling references ## Interactive UX diff --git a/Makefile b/Makefile deleted file mode 100644 index 9ba31c2..0000000 --- a/Makefile +++ /dev/null @@ -1,78 +0,0 @@ -SHELL := /usr/bin/env bash - -PREFIX ?= $(HOME)/.local -BINDIR ?= $(PREFIX)/bin -XDG_DATA_HOME ?= $(HOME)/.local/share -MANDIR ?= $(XDG_DATA_HOME)/man -MAN1DIR ?= $(MANDIR)/man1 -PANDOC ?= pandoc - -SCRIPTS := \ - general/mktlapse \ - general/nmapscan \ - general/selfcert \ - general/strip-ext \ - qemu/vmctl - -MAN_SOURCES := \ - man/mktlapse.1.md \ - man/nmapscan.1.md \ - man/selfcert.1.md \ - man/strip-ext.1.md \ - man/vmctl.1.md - -MAN_PAGES := $(patsubst man/%.md,build/man/%,$(MAN_SOURCES)) - -.PHONY: install install-scripts install-man uninstall uninstall-man list man clean-man check-pandoc - -install: install-scripts install-man - -install-scripts: - @mkdir -p "$(BINDIR)" - @for script in $(SCRIPTS); do \ - name="$$(basename "$$script")"; \ - install -m 0755 "$$script" "$(BINDIR)/$$name"; \ - echo "Installed $$name -> $(BINDIR)/$$name"; \ - done - -install-man: $(MAN_PAGES) - @mkdir -p "$(MAN1DIR)" - @for page in $(MAN_PAGES); do \ - name="$$(basename "$$page")"; \ - install -m 0644 "$$page" "$(MAN1DIR)/$$name"; \ - echo "Installed $$name -> $(MAN1DIR)/$$name"; \ - done - -uninstall: uninstall-man - @for script in $(SCRIPTS); do \ - name="$$(basename "$$script")"; \ - rm -f "$(BINDIR)/$$name"; \ - echo "Removed $(BINDIR)/$$name"; \ - done - -uninstall-man: - @for source in $(MAN_SOURCES); do \ - name="$$(basename "$${source%.md}")"; \ - rm -f "$(MAN1DIR)/$$name"; \ - echo "Removed $(MAN1DIR)/$$name"; \ - done - -list: - @printf '%s\n' $(SCRIPTS) - -man: $(MAN_PAGES) - -clean-man: - @rm -rf build/man - -check-pandoc: - @command -v "$(PANDOC)" >/dev/null 2>&1 || { \ - echo "pandoc is required to build man pages" >&2; \ - exit 1; \ - } - -build/man: - @mkdir -p "$@" - -build/man/%: man/%.md | build/man check-pandoc - @$(PANDOC) --standalone --to man "$<" -o "$@" diff --git a/justfile b/justfile new file mode 100644 index 0000000..d42d205 --- /dev/null +++ b/justfile @@ -0,0 +1,90 @@ +prefix := env_var_or_default("PREFIX", home_dir() / ".local") +bindir := env_var_or_default("BINDIR", prefix / "bin") +xdg_data_home := env_var_or_default("XDG_DATA_HOME", home_dir() / ".local/share") +mandir := env_var_or_default("MANDIR", xdg_data_home / "man") +man1dir := env_var_or_default("MAN1DIR", mandir / "man1") +pandoc := env_var_or_default("PANDOC", "pandoc") + +scripts := "general/mktlapse general/nmapscan general/selfcert general/strip-ext qemu/vmctl" +man_sources := "man/mktlapse.1.md man/nmapscan.1.md man/selfcert.1.md man/strip-ext.1.md man/vmctl.1.md" + +# Default action: install scripts and man pages +default: install + +# Install all scripts and man pages +install: install-scripts install-man + +# Install only utility scripts +install-scripts: + #!/usr/bin/env bash + set -euo pipefail + mkdir -p "{{bindir}}" + for script in {{scripts}}; do + name=$(basename "$script") + install -m 0755 "$script" "{{bindir}}/$name" + echo "Installed $name -> {{bindir}}/$name" + done + +# Install only man pages (builds them if needed) +install-man: man + #!/usr/bin/env bash + set -euo pipefail + mkdir -p "{{man1dir}}" + for source in {{man_sources}}; do + name=$(basename "${source%.md}") + install -m 0644 "build/man/$name" "{{man1dir}}/$name" + echo "Installed $name -> {{man1dir}}/$name" + done + +# Build all man pages from Markdown sources +man: _check-pandoc + #!/usr/bin/env bash + set -euo pipefail + mkdir -p build/man + for source in {{man_sources}}; do + name=$(basename "${source%.md}") + "{{pandoc}}" --standalone --to man "$source" -o "build/man/$name" + echo "Built build/man/$name" + done + +# Uninstall all scripts and man pages +uninstall: uninstall-scripts uninstall-man + +# Uninstall utility scripts +uninstall-scripts: + #!/usr/bin/env bash + set -euo pipefail + for script in {{scripts}}; do + name=$(basename "$script") + rm -f "{{bindir}}/$name" + echo "Removed {{bindir}}/$name" + done + +# Uninstall man pages +uninstall-man: + #!/usr/bin/env bash + set -euo pipefail + for source in {{man_sources}}; do + name=$(basename "${source%.md}") + rm -f "{{man1dir}}/$name" + echo "Removed {{man1dir}}/$name" + done + +# List all scripts in the repository +list: + #!/usr/bin/env bash + set -euo pipefail + for script in {{scripts}}; do + echo "$script" + done + +# Clean generated man page build artifacts +clean-man: + rm -rf build/man + +# Verify if pandoc is installed +_check-pandoc: + @command -v "{{pandoc}}" >/dev/null 2>&1 || { \ + echo "pandoc is required to build man pages" >&2; \ + exit 1; \ + } From 8f63b71a2dfd0bc2cd6dd6419732482ef5b3c5d4 Mon Sep 17 00:00:00 2001 From: Mark Pitman Date: Thu, 25 Jun 2026 23:27:38 -0700 Subject: [PATCH 4/6] Add git-clone-all script and man page Implement a development utility script to recursively clone all git repositories for an organization, group, or user on GitHub or GitLab. The script uses 'gh'/'glab' CLI tools if available, with a curl API fallback. Clones via SSH and falls back to HTTPS. --- development/git-clone-all | 349 ++++++++++++++++++++++++++++++++++++++ justfile | 4 +- man/git-clone-all.1.md | 79 +++++++++ 3 files changed, 430 insertions(+), 2 deletions(-) create mode 100755 development/git-clone-all create mode 100644 man/git-clone-all.1.md diff --git a/development/git-clone-all b/development/git-clone-all new file mode 100755 index 0000000..cb1d257 --- /dev/null +++ b/development/git-clone-all @@ -0,0 +1,349 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + echo "Usage: $(basename "$0") [-n] [-d ] " + echo "" + echo "Recursively clone all git repositories from a GitHub or GitLab user/organization." + echo "" + echo "Options:" + echo " -d Base directory to clone repositories into (default: .)" + echo " -n Dry-run: show what would be cloned without making changes" + echo " -h Show this help message and exit" + echo "" + echo "Examples:" + echo " $(basename "$0") github.com/mapitman" + echo " $(basename "$0") gitlab.com/gitlab-org" + echo " $(basename "$0") -d ~/src github.com/mapitman" + echo " $(basename "$0") -n https://github.com/mapitman" +} + +base_dir="." +dry_run=false + +while getopts ":nd:h" opt; do + case $opt in + n) dry_run=true ;; + d) base_dir="$OPTARG" ;; + h) usage; exit 0 ;; + \?) echo "Unknown option: -$OPTARG" >&2; usage >&2; exit 1 ;; + :) echo "Option -$OPTARG requires an argument" >&2; usage >&2; exit 1 ;; + esac +done +shift $((OPTIND - 1)) + +if [[ $# -ne 1 ]]; then + echo "Error: missing user/org path" >&2 + usage >&2 + exit 1 +fi + +target_path="$1" + +# Normalize target path +input="$target_path" +input="${input#http://}" +input="${input#https://}" +input="${input#ssh://}" +input="${input#git@}" +input="${input//://}" +input="${input#/}" + +domain="${input%%/*}" +path="${input#*/}" +path="${path%/}" + +if [[ -z "$domain" || -z "$path" ]]; then + echo "Error: invalid path format. Expected format: [host]/[user-or-org]" >&2 + usage >&2 + exit 1 +fi + +temp_repos=$(mktemp) +trap 'rm -f "$temp_repos"' EXIT + +fetch_github_repos() { + local path="$1" + local token="${GITHUB_TOKEN:-${GH_TOKEN:-}}" + + # Try using gh CLI first + if command -v gh >/dev/null 2>&1; then + echo "Attempting to list repositories using 'gh' CLI..." >&2 + local gh_output + if gh_output=$(gh repo list "$path" --limit 10000 --json sshUrl,url,nameWithOwner --jq '.[] | "\(.sshUrl)\t\(.url)\t\(.nameWithOwner)"' 2>/dev/null); then + if [[ -n "$gh_output" ]]; then + echo "$gh_output" >> "$temp_repos" + echo "Successfully retrieved repositories using 'gh' CLI." >&2 + return 0 + fi + fi + echo "Warning: 'gh' CLI failed or is not authenticated. Falling back to direct API calls..." >&2 + fi + + # Fallback to direct API calls via curl + local auth_header=() + if [[ -n "$token" ]]; then + auth_header=(-H "Authorization: Bearer $token") + fi + + # Determine if user or org + echo "Querying GitHub user/org information for '$path'..." >&2 + local type_response + local http_code + local body_file + body_file=$(mktemp) + + http_code=$(curl -s "${auth_header[@]}" -w "%{http_code}" -o "$body_file" "https://api.github.com/users/$path") + + if [[ "$http_code" -ne 200 ]]; then + local msg + msg=$(jq -r '.message // empty' "$body_file" 2>/dev/null || cat "$body_file") + echo "Error: Failed to retrieve GitHub user/org info for '$path' (HTTP $http_code)." >&2 + if [[ -n "$msg" ]]; then + echo "Message: $msg" >&2 + fi + rm -f "$body_file" + exit 1 + fi + type_response=$(cat "$body_file") + rm -f "$body_file" + + local user_type + user_type=$(echo "$type_response" | jq -r '.type // empty') + + local api_url + if [[ "$user_type" == "Organization" ]]; then + api_url="https://api.github.com/orgs/$path/repos" + else + api_url="https://api.github.com/users/$path/repos" + fi + + local page=1 + while true; do + local page_file + page_file=$(mktemp) + local page_http_code + page_http_code=$(curl -s "${auth_header[@]}" -w "%{http_code}" -o "$page_file" "$api_url?per_page=100&page=$page") + + if [[ "$page_http_code" -ne 200 ]]; then + local msg + msg=$(jq -r '.message // empty' "$page_file" 2>/dev/null || cat "$page_file") + echo "Error: Failed to fetch repositories for page $page (HTTP $page_http_code)." >&2 + if [[ -n "$msg" ]]; then + echo "Message: $msg" >&2 + fi + rm -f "$page_file" + exit 1 + fi + + local count + count=$(jq '. | length' "$page_file") + if [[ $count -eq 0 ]]; then + rm -f "$page_file" + break + fi + + # Extract SSH url, HTTPS url, and full path + jq -r '.[] | "\(.ssh_url)\t\(.clone_url)\t\(.full_name)"' "$page_file" >> "$temp_repos" + + rm -f "$page_file" + + if [[ $count -lt 100 ]]; then + break + fi + page=$((page + 1)) + done +} + +fetch_gitlab_repos() { + local path="$1" + local token="${GITLAB_TOKEN:-${GL_TOKEN:-${PRIVATE_TOKEN:-}}}" + local encoded_path="${path//\//%2F}" + + # Try using glab CLI first (using 'glab api') + if command -v glab >/dev/null 2>&1; then + echo "Attempting to list repositories using 'glab' CLI..." >&2 + local glab_output + if glab_output=$(glab api "groups/$encoded_path/projects?include_subgroups=true&per_page=100" --all 2>/dev/null); then + if [[ -n "$glab_output" && $(echo "$glab_output" | jq '. | length' 2>/dev/null) -gt 0 ]]; then + echo "$glab_output" | jq -r '.[] | "\(.ssh_url_to_repo)\t\(.http_url_to_repo)\t\(.path_with_namespace)"' >> "$temp_repos" + echo "Successfully retrieved group repositories using 'glab' CLI." >&2 + return 0 + fi + fi + + if glab_output=$(glab api "users/$path/projects?per_page=100" --all 2>/dev/null); then + if [[ -n "$glab_output" && $(echo "$glab_output" | jq '. | length' 2>/dev/null) -gt 0 ]]; then + echo "$glab_output" | jq -r '.[] | "\(.ssh_url_to_repo)\t\(.http_url_to_repo)\t\(.path_with_namespace)"' >> "$temp_repos" + echo "Successfully retrieved user repositories using 'glab' CLI." >&2 + return 0 + fi + fi + echo "Warning: 'glab' CLI failed, is not authenticated, or returned empty. Falling back to direct API calls..." >&2 + fi + + # Fallback to direct API calls via curl + local auth_header=() + if [[ -n "$token" ]]; then + auth_header=(-H "PRIVATE-TOKEN: $token") + fi + + # Determine if group or user + echo "Querying GitLab group information for '$path'..." >&2 + local group_file + group_file=$(mktemp) + local group_http_code + group_http_code=$(curl -s "${auth_header[@]}" -w "%{http_code}" -o "$group_file" "https://gitlab.com/api/v4/groups/$encoded_path") + + local is_group=false + if [[ "$group_http_code" -eq 200 ]]; then + is_group=true + fi + rm -f "$group_file" + + local api_url + if $is_group; then + api_url="https://gitlab.com/api/v4/groups/$encoded_path/projects" + else + echo "Group not found or inaccessible. Querying GitLab user information for '$path'..." >&2 + local user_file + user_file=$(mktemp) + local user_http_code + user_http_code=$(curl -s "${auth_header[@]}" -w "%{http_code}" -o "$user_file" "https://gitlab.com/api/v4/users?username=$path") + + if [[ "$user_http_code" -ne 200 ]]; then + local msg + msg=$(jq -r '.message // empty' "$user_file" 2>/dev/null || cat "$user_file") + echo "Error: Failed to verify GitLab user/group '$path' (HTTP $user_http_code)." >&2 + if [[ -n "$msg" ]]; then + echo "Message: $msg" >&2 + fi + rm -f "$user_file" + exit 1 + fi + + local user_count + user_count=$(jq '. | length' "$user_file") + if [[ $user_count -eq 0 ]]; then + echo "Error: GitLab user or group '$path' could not be found." >&2 + rm -f "$user_file" + exit 1 + fi + rm -f "$user_file" + + api_url="https://gitlab.com/api/v4/users/$path/projects" + fi + + local page=1 + while true; do + local page_file + page_file=$(mktemp) + local page_http_code + local query_url + if $is_group; then + query_url="$api_url?include_subgroups=true&per_page=100&page=$page" + else + query_url="$api_url?per_page=100&page=$page" + fi + + page_http_code=$(curl -s "${auth_header[@]}" -w "%{http_code}" -o "$page_file" "$query_url") + + if [[ "$page_http_code" -ne 200 ]]; then + local msg + msg=$(jq -r '.message // empty' "$page_file" 2>/dev/null || cat "$page_file") + echo "Error: Failed to fetch repositories for page $page (HTTP $page_http_code)." >&2 + if [[ -n "$msg" ]]; then + echo "Message: $msg" >&2 + fi + rm -f "$page_file" + exit 1 + fi + + local count + count=$(jq '. | length' "$page_file") + if [[ $count -eq 0 ]]; then + rm -f "$page_file" + break + fi + + # Extract SSH url, HTTPS url, and path with namespace + jq -r '.[] | "\(.ssh_url_to_repo)\t\(.http_url_to_repo)\t\(.path_with_namespace)"' "$page_file" >> "$temp_repos" + + rm -f "$page_file" + + if [[ $count -lt 100 ]]; then + break + fi + page=$((page + 1)) + done +} + +case "$domain" in + github.com|www.github.com) + fetch_github_repos "$path" + ;; + gitlab.com|www.gitlab.com) + fetch_gitlab_repos "$path" + ;; + *) + echo "Error: unsupported domain '$domain'. Only github.com and gitlab.com are supported." >&2 + exit 1 + ;; +esac + +if [[ ! -s "$temp_repos" ]]; then + echo "No repositories found for '$target_path'." + exit 0 +fi + +total_repos=$(wc -l < "$temp_repos") +echo "Found $total_repos repositories to clone." + +current_repo=0 +while IFS=$'\t' read -r ssh_url clone_url repo_path; do + current_repo=$((current_repo + 1)) + + # Strip whitespace/CRLF + ssh_url=$(echo "$ssh_url" | xargs) + clone_url=$(echo "$clone_url" | xargs) + repo_path=$(echo "$repo_path" | xargs) + + target_dir="$base_dir/$repo_path" + + echo "[$current_repo/$total_repos] Processing '$repo_path'..." + + if [[ -d "$target_dir" ]]; then + echo " [-] Directory '$target_dir' already exists. Skipping clone." + continue + fi + + if $dry_run; then + echo " [Dry-run] Would clone via SSH: git clone -- '$ssh_url' '$target_dir'" + echo " Fallback via HTTPS: git clone -- '$clone_url' '$target_dir'" + continue + fi + + # Create parent directory + mkdir -p "$(dirname "$target_dir")" + + # Clone + ssh_err=$(mktemp) + if git clone -- "$ssh_url" "$target_dir" 2> "$ssh_err"; then + echo " Successfully cloned via SSH." + rm -f "$ssh_err" + else + https_err=$(mktemp) + if git clone -- "$clone_url" "$target_dir" 2> "$https_err"; then + echo " SSH clone failed (falling back). Successfully cloned via HTTPS." + rm -f "$ssh_err" "$https_err" + else + echo " [ERROR] Failed to clone '$repo_path' via both SSH and HTTPS." >&2 + echo " --- SSH clone error ---" >&2 + cat "$ssh_err" | sed 's/^/ /' >&2 + echo " --- HTTPS clone error ---" >&2 + cat "$https_err" | sed 's/^/ /' >&2 + rm -f "$ssh_err" "$https_err" + fi + fi +done < "$temp_repos" diff --git a/justfile b/justfile index d42d205..7161971 100644 --- a/justfile +++ b/justfile @@ -5,8 +5,8 @@ mandir := env_var_or_default("MANDIR", xdg_data_home / "man") man1dir := env_var_or_default("MAN1DIR", mandir / "man1") pandoc := env_var_or_default("PANDOC", "pandoc") -scripts := "general/mktlapse general/nmapscan general/selfcert general/strip-ext qemu/vmctl" -man_sources := "man/mktlapse.1.md man/nmapscan.1.md man/selfcert.1.md man/strip-ext.1.md man/vmctl.1.md" +scripts := "development/git-clone-all general/mktlapse general/nmapscan general/selfcert general/strip-ext qemu/vmctl" +man_sources := "man/git-clone-all.1.md man/mktlapse.1.md man/nmapscan.1.md man/selfcert.1.md man/strip-ext.1.md man/vmctl.1.md" # Default action: install scripts and man pages default: install diff --git a/man/git-clone-all.1.md b/man/git-clone-all.1.md new file mode 100644 index 0000000..f67cfec --- /dev/null +++ b/man/git-clone-all.1.md @@ -0,0 +1,79 @@ +--- +title: GIT-CLONE-ALL +section: 1 +header: User Commands +date: June 2026 +author: Mark Pitman +--- + +# NAME + +git-clone-all - recursively clone all git repositories from a GitHub or GitLab user or organization + +# SYNOPSIS + +**git-clone-all** [-n] [-d *dir*] [-h] *org-or-user-path* + +# DESCRIPTION + +**git-clone-all** retrieves the list of all repositories belonging to the specified user, organization, or group from GitHub or GitLab, and clones them locally. + +By default, the script creates directories that replicate the user or organization structure (e.g., `mapitman/scripts` for GitHub, or `group/subgroup/project` for GitLab) under the destination directory. + +For each repository, the script attempts to clone using **SSH** first. If the SSH clone fails, it falls back to **HTTPS**. + +If the destination directory for a repository already exists, it is skipped. + +# INTEGRATION & AUTHENTICATION + +The script automatically prioritizes using CLI utilities if they are installed and configured: + +**GitHub** + +: The script uses the `gh` CLI utility if available. To authenticate or list private repositories, make sure `gh` is logged in (`gh auth login`). If `gh` is not available or is unauthenticated, the script falls back to direct API queries using `curl` and can utilize `GITHUB_TOKEN` or `GH_TOKEN` environment variables. + +**GitLab** + +: The script uses the `glab` CLI utility (via `glab api`) if available. If `glab` is not available, it falls back to direct API queries using `curl` and can utilize `GITLAB_TOKEN`, `GL_TOKEN`, or `PRIVATE_TOKEN` environment variables. + +# OPTIONS + +**-d** *dir* + +: Specify the base directory where repositories should be cloned. Defaults to the current directory (`.`). + +**-n** + +: Dry-run. Print the commands that would be executed without cloning any repositories. + +**-h** + +: Show usage help and exit. + +# EXAMPLES + +Clone all repositories under GitHub user `mapitman`: + +```bash +git-clone-all github.com/mapitman +``` + +Clone all repositories under GitLab group `gitlab-org` into a custom source folder, using dry-run first: + +```bash +git-clone-all -n -d ~/src gitlab.com/gitlab-org +``` + +# EXIT STATUS + +**0** + +: Success. + +**1** + +: Invalid usage, API error, or another error occurred. + +# SEE ALSO + +**git-clone**(1), **gh**(1), **glab**(1) From 7450d02f116cdc8ec8ec46e4a178c3243e50e416 Mon Sep 17 00:00:00 2001 From: Mark Pitman Date: Fri, 26 Jun 2026 00:23:34 -0700 Subject: [PATCH 5/6] Skip archived repositories by default Skip archived repositories by default when listing and cloning, and add a new '-a' option flag to optionally include archived repositories. Updated both CLI integration (gh/glab) and API curl fallbacks. --- development/git-clone-all | 35 ++++++++++++++++++++++++++++------- man/git-clone-all.1.md | 6 +++++- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/development/git-clone-all b/development/git-clone-all index cb1d257..240fffa 100755 --- a/development/git-clone-all +++ b/development/git-clone-all @@ -3,28 +3,31 @@ set -euo pipefail usage() { - echo "Usage: $(basename "$0") [-n] [-d ] " + echo "Usage: $(basename "$0") [-n] [-a] [-d ] " echo "" echo "Recursively clone all git repositories from a GitHub or GitLab user/organization." echo "" echo "Options:" echo " -d Base directory to clone repositories into (default: .)" + echo " -a Clone archived repositories as well (by default, they are skipped)" echo " -n Dry-run: show what would be cloned without making changes" echo " -h Show this help message and exit" echo "" echo "Examples:" echo " $(basename "$0") github.com/mapitman" - echo " $(basename "$0") gitlab.com/gitlab-org" + echo " $(basename "$0") -a gitlab.com/gitlab-org" echo " $(basename "$0") -d ~/src github.com/mapitman" echo " $(basename "$0") -n https://github.com/mapitman" } base_dir="." dry_run=false +clone_archived=false -while getopts ":nd:h" opt; do +while getopts ":nad:h" opt; do case $opt in n) dry_run=true ;; + a) clone_archived=true ;; d) base_dir="$OPTARG" ;; h) usage; exit 0 ;; \?) echo "Unknown option: -$OPTARG" >&2; usage >&2; exit 1 ;; @@ -71,7 +74,11 @@ fetch_github_repos() { if command -v gh >/dev/null 2>&1; then echo "Attempting to list repositories using 'gh' CLI..." >&2 local gh_output - if gh_output=$(gh repo list "$path" --limit 10000 --json sshUrl,url,nameWithOwner --jq '.[] | "\(.sshUrl)\t\(.url)\t\(.nameWithOwner)"' 2>/dev/null); then + local jq_filter='.[]' + if ! $clone_archived; then + jq_filter='.[] | select(.isArchived | not)' + fi + if gh_output=$(gh repo list "$path" --limit 10000 --json sshUrl,url,nameWithOwner,isArchived --jq "$jq_filter | \"\(.sshUrl)\t\(.url)\t\(.nameWithOwner)\"" 2>/dev/null); then if [[ -n "$gh_output" ]]; then echo "$gh_output" >> "$temp_repos" echo "Successfully retrieved repositories using 'gh' CLI." >&2 @@ -145,7 +152,11 @@ fetch_github_repos() { fi # Extract SSH url, HTTPS url, and full path - jq -r '.[] | "\(.ssh_url)\t\(.clone_url)\t\(.full_name)"' "$page_file" >> "$temp_repos" + if $clone_archived; then + jq -r '.[] | "\(.ssh_url)\t\(.clone_url)\t\(.full_name)"' "$page_file" >> "$temp_repos" + else + jq -r '.[] | select(.archived | not) | "\(.ssh_url)\t\(.clone_url)\t\(.full_name)"' "$page_file" >> "$temp_repos" + fi rm -f "$page_file" @@ -165,7 +176,11 @@ fetch_gitlab_repos() { if command -v glab >/dev/null 2>&1; then echo "Attempting to list repositories using 'glab' CLI..." >&2 local glab_output - if glab_output=$(glab api "groups/$encoded_path/projects?include_subgroups=true&per_page=100" --all 2>/dev/null); then + local archived_filter="" + if ! $clone_archived; then + archived_filter="&archived=false" + fi + if glab_output=$(glab api "groups/$encoded_path/projects?include_subgroups=true${archived_filter}&per_page=100" --all 2>/dev/null); then if [[ -n "$glab_output" && $(echo "$glab_output" | jq '. | length' 2>/dev/null) -gt 0 ]]; then echo "$glab_output" | jq -r '.[] | "\(.ssh_url_to_repo)\t\(.http_url_to_repo)\t\(.path_with_namespace)"' >> "$temp_repos" echo "Successfully retrieved group repositories using 'glab' CLI." >&2 @@ -173,7 +188,7 @@ fetch_gitlab_repos() { fi fi - if glab_output=$(glab api "users/$path/projects?per_page=100" --all 2>/dev/null); then + if glab_output=$(glab api "users/$path/projects?per_page=100${archived_filter}" --all 2>/dev/null); then if [[ -n "$glab_output" && $(echo "$glab_output" | jq '. | length' 2>/dev/null) -gt 0 ]]; then echo "$glab_output" | jq -r '.[] | "\(.ssh_url_to_repo)\t\(.http_url_to_repo)\t\(.path_with_namespace)"' >> "$temp_repos" echo "Successfully retrieved user repositories using 'glab' CLI." >&2 @@ -243,8 +258,14 @@ fetch_gitlab_repos() { local query_url if $is_group; then query_url="$api_url?include_subgroups=true&per_page=100&page=$page" + if ! $clone_archived; then + query_url="${query_url}&archived=false" + fi else query_url="$api_url?per_page=100&page=$page" + if ! $clone_archived; then + query_url="${query_url}&archived=false" + fi fi page_http_code=$(curl -s "${auth_header[@]}" -w "%{http_code}" -o "$page_file" "$query_url") diff --git a/man/git-clone-all.1.md b/man/git-clone-all.1.md index f67cfec..06edabb 100644 --- a/man/git-clone-all.1.md +++ b/man/git-clone-all.1.md @@ -12,7 +12,7 @@ git-clone-all - recursively clone all git repositories from a GitHub or GitLab u # SYNOPSIS -**git-clone-all** [-n] [-d *dir*] [-h] *org-or-user-path* +**git-clone-all** [-n] [-a] [-d *dir*] [-h] *org-or-user-path* # DESCRIPTION @@ -42,6 +42,10 @@ The script automatically prioritizes using CLI utilities if they are installed a : Specify the base directory where repositories should be cloned. Defaults to the current directory (`.`). +**-a** + +: Clone archived repositories as well. By default, archived repositories are skipped. + **-n** : Dry-run. Print the commands that would be executed without cloning any repositories. From bd1dc80077b83e3a943ed35041297651b5ce6969 Mon Sep 17 00:00:00 2001 From: Mark Pitman Date: Fri, 26 Jun 2026 00:42:20 -0700 Subject: [PATCH 6/6] Add git-status-all script and man page --- development/git-status-all | 153 +++++++++++++++++++++++++++++++++++++ justfile | 4 +- man/git-status-all.1.md | 59 ++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) create mode 100755 development/git-status-all create mode 100644 man/git-status-all.1.md diff --git a/development/git-status-all b/development/git-status-all new file mode 100755 index 0000000..6ecc12e --- /dev/null +++ b/development/git-status-all @@ -0,0 +1,153 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + echo "Usage: $(basename "$0") [-f] [-h] []" + echo "" + echo "Iterates over all Git repositories in a directory and reports which ones" + echo "have uncommitted changes or unpushed commits." + echo "" + echo "Options:" + echo " -f Fetch from remotes before checking for unpushed commits" + echo " -h Show this help message and exit" + echo "" + echo "Examples:" + echo " $(basename "$0")" + echo " $(basename "$0") -f ~/src" +} + +fetch=false + +while getopts ":fh" opt; do + case $opt in + f) fetch=true ;; + h) usage; exit 0 ;; + \?) echo "Unknown option: -$OPTARG" >&2; usage >&2; exit 1 ;; + esac +done +shift $((OPTIND - 1)) + +if [[ $# -gt 1 ]]; then + echo "Error: too many arguments" >&2 + usage >&2 + exit 1 +fi + +# Setup colors and icons if output is a terminal +if [[ -t 1 ]]; then + BOLD="\033[1m" + GREEN="\033[32m" + YELLOW="\033[33m" + RED="\033[31m" + BLUE="\033[34m" + NC="\033[0m" # No Color + + CLEAN_ICON="✨ " + DIRTY_ICON="📦 " + UNCOMMITTED_ICON="⚠️ " + UNPUSHED_ICON="⬆️ " + WARNING_ICON="⚠️ " +else + BOLD="" + GREEN="" + YELLOW="" + RED="" + BLUE="" + NC="" + + CLEAN_ICON="" + DIRTY_ICON="" + UNCOMMITTED_ICON="" + UNPUSHED_ICON="" + WARNING_ICON="" +fi + +target_dir="." +if [[ $# -eq 1 ]]; then + target_dir="$1" +fi + +if [[ ! -d "$target_dir" ]]; then + echo "Error: '$target_dir' is not a directory" >&2 + exit 1 +fi + +target_dir=$(realpath -- "$target_dir") + +# Find all git repositories (directories containing a .git entry) +repos=() +while IFS= read -r -d '' gitdir; do + repos+=("$(dirname "$gitdir")") +done < <(find "$target_dir" -name .git -prune -print0 2>/dev/null | sort -z) + +if [[ ${#repos[@]} -eq 0 ]]; then + echo "No Git repositories found under '$target_dir'." + exit 0 +fi + +dirty_count=0 + +for repo in "${repos[@]}"; do + if [[ ! -d "$repo" ]]; then + continue + fi + + # Double-check that it is a valid git repository + if ! git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + continue + fi + + # Fetch remotes if requested and if any remotes exist + if $fetch; then + if git -C "$repo" remote | grep -q .; then + git -C "$repo" fetch --all --prune >/dev/null 2>&1 || echo -e "${RED}${WARNING_ICON}Warning: failed to fetch in '$repo'${NC}" >&2 + fi + fi + + dirty=false + uncommitted=false + status_output="" + + if status_output=$(git -C "$repo" status --porcelain 2>/dev/null); then + if [[ -n "$status_output" ]]; then + uncommitted=true + dirty=true + fi + fi + + unpushed=false + unpushed_commits="" + + # Only check for unpushed commits if at least one remote is configured + if git -C "$repo" remote | grep -q .; then + if unpushed_commits=$(git -C "$repo" log --branches --not --remotes --oneline 2>/dev/null); then + if [[ -n "$unpushed_commits" ]]; then + unpushed=true + dirty=true + fi + fi + fi + + if $dirty; then + dirty_count=$((dirty_count + 1)) + echo -e "${BOLD}${DIRTY_ICON}${repo}${NC}" + if $uncommitted; then + echo -e " ${YELLOW}${UNCOMMITTED_ICON}Uncommitted changes:${NC}" + echo "$status_output" | sed 's/^/ /' + fi + if $unpushed; then + echo -e " ${BLUE}${UNPUSHED_ICON}Unpushed commits:${NC}" + echo "$unpushed_commits" | sed 's/^/ /' + fi + echo "" + fi +done + +if [[ $dirty_count -eq 0 ]]; then + if [[ ${#repos[@]} -eq 1 ]]; then + echo -e "${GREEN}${CLEAN_ICON}The only Git repository under '$target_dir' is clean.${NC}" + else + echo -e "${GREEN}${CLEAN_ICON}All ${#repos[@]} Git repositories under '$target_dir' are clean.${NC}" + fi +fi diff --git a/justfile b/justfile index 7161971..ef896a0 100644 --- a/justfile +++ b/justfile @@ -5,8 +5,8 @@ mandir := env_var_or_default("MANDIR", xdg_data_home / "man") man1dir := env_var_or_default("MAN1DIR", mandir / "man1") pandoc := env_var_or_default("PANDOC", "pandoc") -scripts := "development/git-clone-all general/mktlapse general/nmapscan general/selfcert general/strip-ext qemu/vmctl" -man_sources := "man/git-clone-all.1.md man/mktlapse.1.md man/nmapscan.1.md man/selfcert.1.md man/strip-ext.1.md man/vmctl.1.md" +scripts := "development/git-clone-all development/git-status-all general/mktlapse general/nmapscan general/selfcert general/strip-ext qemu/vmctl" +man_sources := "man/git-clone-all.1.md man/git-status-all.1.md man/mktlapse.1.md man/nmapscan.1.md man/selfcert.1.md man/strip-ext.1.md man/vmctl.1.md" # Default action: install scripts and man pages default: install diff --git a/man/git-status-all.1.md b/man/git-status-all.1.md new file mode 100644 index 0000000..d1b3fce --- /dev/null +++ b/man/git-status-all.1.md @@ -0,0 +1,59 @@ +--- +title: GIT-STATUS-ALL +section: 1 +header: User Commands +date: June 2026 +author: Mark Pitman +--- + +# NAME + +git-status-all - check the status of all Git repositories under a directory + +# SYNOPSIS + +**git-status-all** [-f] [-h] [*dir*] + +# DESCRIPTION + +**git-status-all** scans the specified directory recursively for directories containing a `.git` folder, and checks each one for uncommitted changes or unpushed commits. + +Only repositories that have uncommitted changes or unpushed commits are output by default. If all scanned repositories are clean, a summary message to that effect is shown. + +# OPTIONS + +**-f** + +: Fetch from remotes before checking for unpushed commits. This provides an up-to-date view of the remote branches, but requires network connectivity. + +**-h** + +: Show usage help and exit. + +# EXAMPLES + +Check the status of repositories in the current directory: + +```bash +git-status-all +``` + +Check the status of repositories under `~/src`, fetching remote updates first: + +```bash +git-status-all -f ~/src +``` + +# EXIT STATUS + +**0** + +: Success. + +**1** + +: Invalid usage, missing directory, or another error occurred. + +# SEE ALSO + +**git-status**(1), **git-log**(1), **git-fetch**(1)