#!/bin/bash VERSION="0.2.25" VERSION_DATE="2026-11-10" CONFIG_KEY="gits.sharedSubmodules" CLEAN_GRACE_SECONDS=2592000 RED=$'\033[1;31m' GREEN=$'\033[1;32m' YELLOW=$'\033[1;33m' RESET=$'\033[0m' SHARED_LOCK_DIR="" print_cmd() { printf '%s%s%s\n' "$GREEN" "$1" "$RESET" } print_red() { printf '%s%s%s\n' "$RED" "$1" "$RESET" } error() { printf '%s%s%s\n' "$RED" "gits: $*" "$RESET" >&2 return 1 } require_git() { command -v git >/dev/null 2>&1 || { error "Git is required. Install it with 'brew install git', then retry." return 1 } } git_root() { git rev-parse --show-toplevel 2>/dev/null } print_version() { printf 'gits version %s (%s)\n' "$VERSION" "$VERSION_DATE" printf 'https://github.com/leo1394/homebrew-gits\n' } usage() { cat <<'EOF' usage: gits [-C ] [] gits [-C ] help [] gits [-C ] [--help | --version] Global option: -C Run as if gits was started in instead of the current directory These are common gits commands: work with submodules init Initialize submodules, optionally with shared objects pull Fast-forward the repository and selected submodules reset Reset the repository or selected submodules add Add a submodule through git submodule add admit Stage selected changes, edit a message, and commit manage shared Git objects config View, enable, or disable shared mode for this project list Show submodules and shared repository state cleanup Safely preview or remove unused shared mirrors unlock Release a stale lock or terminate its gits owner inspect and maintain submodules status Show submodule status update Update registered submodules deinit Unregister selected submodules Run 'gits help ' or 'gits --help' for detailed help. EOF } help_usage() { cat <<'EOF' usage: gits help [] With no command, show the root overview. With one command, show its detailed help. EOF } command_help() { case "$1" in help) help_usage ;; version) cat <<'EOF' usage: gits version gits --version Show the installed gits version. Output includes the release date and project homepage. EOF ;; init) cat <<'EOF' usage: gits init [] Synchronize and initialize the current project's submodules. Without a path, use standard Git submodule storage. With a path, enable the project-scoped shared object repository at that canonical location, then initialize the submodules. The setting is written only to this repository's local Git config. EOF ;; pull) cat <<'EOF' usage: gits pull [... | --all] Fast-forward the superproject, then fast-forward selected initialized submodules on their current branches. Without paths, update every declared top-level submodule. --all is an explicit equivalent. If the current superproject branch has no upstream and origin contains a same-named branch, configure that branch as its upstream before pulling. The command never chooses a different branch implicitly. A path may include or omit its trailing slash. EOF ;; reset) cat <<'EOF' usage: gits reset [--hard] [... | --all] Without --hard, unstage changes in the superproject and selected submodules. With --hard, discard tracked changes and restore the recorded submodule commits. Without paths, process the repository and every declared submodule. Use --hard carefully: discarded tracked changes cannot be recovered by gits. EOF ;; add) cat <<'EOF' usage: gits add ... Pass all remaining arguments to git submodule add. Common form: gits add [-b ] [--name ] [--reference ] \ [--] [] EOF ;; admit) cat <<'EOF' usage: gits admit (... | --all) Stage selected paths, open the Git-configured editor with a context-aware default message, and create a commit. --all selects every submodule declared in .gitmodules. Previously staged unrelated paths remain staged for a later commit. During a merge, Git commits the complete resolved index. If message editing or commit creation is cancelled, gits restores the index to its pre-command state. Exiting the editor without saving also cancels the operation. Working tree changes are not discarded. EOF ;; config) cat <<'EOF' usage: gits config [ | --unset] With no argument, show the shared object repository configured for this project. A path enables or changes project-scoped shared mode. --unset disables shared mode and removes gits-managed alternate references from this project. Disabling shared mode does not delete central mirrors or user-managed alternates. EOF ;; list) cat <<'EOF' usage: gits list Show the current project's shared mode and every declared submodule path, URL, and cache state. EOF ;; unlock) cat <<'EOF' usage: gits unlock [--terminate ] Remove a stale shared lock. To stop a live gits owner, explicitly supply its PID. EOF ;; cleanup) cat <<'EOF' usage: gits cleanup [--apply | --dry-run | --list] gits cleanup --append [--append ...] gits cleanup --remove Manage and safely clean the current project's shared mirror repository. --append Register a tree containing mirror consumers; repeatable --remove Remove one registered scan root without deleting mirrors --list List registered scan roots --dry-run Scan and preview without deleting mirrors --apply Delete mirrors eligible after the 30-day unused period With no option, cleanup is equivalent to --apply. Deletion fails closed if a registered root cannot be scanned or mirror ownership cannot be verified. EOF ;; status) cat <<'EOF' usage: gits status [...] Pass all remaining arguments to git submodule status. EOF ;; update) cat <<'EOF' usage: gits update [...] Pass all remaining arguments to git submodule update. EOF ;; deinit) cat <<'EOF' usage: gits deinit [...] Pass all remaining arguments to git submodule deinit. EOF ;; foreach) cat <<'EOF' usage: gits foreach [...] Pass all remaining arguments to git submodule foreach. EOF ;; summary) cat <<'EOF' usage: gits summary [...] Pass all remaining arguments to git submodule summary. EOF ;; sync) cat <<'EOF' usage: gits sync [...] Pass all remaining arguments to git submodule sync. EOF ;; set-branch) cat <<'EOF' usage: gits set-branch [...] Pass all remaining arguments to git submodule set-branch. EOF ;; set-url) cat <<'EOF' usage: gits set-url [...] Pass all remaining arguments to git submodule set-url. EOF ;; absorbgitdirs) cat <<'EOF' usage: gits absorbgitdirs [...] Pass all remaining arguments to git submodule absorbgitdirs. EOF ;; *) print_unknown_command "$1" return 1 ;; esac } run_help() { if [ "$#" -eq 0 ]; then usage return 0 fi if [ "$#" -gt 1 ]; then error "help accepts at most one command" help_usage >&2 return 1 fi case "$1" in -h|--help) help_usage ;; *) command_help "$1" ;; esac } is_supported_command() { case "$1" in init|pull|reset|add|admit|config|list|cleanup|unlock|status|update|deinit|foreach|summary|sync|set-branch|set-url|absorbgitdirs) return 0 ;; *) return 1 ;; esac } quote_command() { local value="$1" value=${value//\\/\\\\} value=${value//$'\033'/\\x1b} value=${value//$'\n'/\\n} value=${value//$'\r'/\\r} value=${value//$'\t'/\\t} value=${value//\'/\\\'} printf "'%s'" "$value" } command_edit_distance() { local left="$1" local right="$2" local left_length=${#left} local right_length=${#right} local width=$((right_length + 1)) local -a distances=() local left_index local right_index local left_character local right_character local replacement_cost local distance local candidate_distance for ((left_index = 0; left_index <= left_length; left_index++)); do distances[$((left_index * width))]=$left_index done for ((right_index = 0; right_index <= right_length; right_index++)); do distances[$right_index]=$right_index done for ((left_index = 1; left_index <= left_length; left_index++)); do left_character=${left:$((left_index - 1)):1} for ((right_index = 1; right_index <= right_length; right_index++)); do right_character=${right:$((right_index - 1)):1} replacement_cost=1 [ "$left_character" = "$right_character" ] && replacement_cost=0 distance=$((${distances[$(((left_index - 1) * width + right_index))]} + 1)) candidate_distance=$((${distances[$((left_index * width + right_index - 1))]} + 1)) [ "$candidate_distance" -ge "$distance" ] || distance=$candidate_distance candidate_distance=$((${distances[$(((left_index - 1) * width + right_index - 1))]} + replacement_cost)) [ "$candidate_distance" -ge "$distance" ] || distance=$candidate_distance if [ "$left_index" -gt 1 ] && [ "$right_index" -gt 1 ] && [ "$left_character" = "${right:$((right_index - 2)):1}" ] && [ "${left:$((left_index - 2)):1}" = "$right_character" ]; then candidate_distance=$((${distances[$(((left_index - 2) * width + right_index - 2))]} + 1)) [ "$candidate_distance" -ge "$distance" ] || distance=$candidate_distance fi distances[$((left_index * width + right_index))]=$distance done done printf '%s\n' "${distances[$((left_length * width + right_length))]}" } similar_commands() { local command local maximum_distance=2 local best_distance local distance local candidate local -a candidates=(absorbgitdirs add admit cleanup config deinit foreach help init list pull reset set-branch set-url status summary sync update version) local -a suggestions=() command=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') || return 1 [ "${#command}" -le 64 ] || return 0 best_distance=$((maximum_distance + 1)) for candidate in "${candidates[@]}"; do distance=$(command_edit_distance "$command" "$candidate") || return 1 if [ "$distance" -gt "$maximum_distance" ] || [ "$distance" -gt "$best_distance" ]; then continue fi if [ "$distance" -lt "$best_distance" ]; then best_distance=$distance suggestions=() fi suggestions+=("$candidate") done printf '%s\n' "${suggestions[@]}" } print_unknown_command() { local command="$1" local suggestions local suggestion local count=0 error "$(quote_command "$command") is not a gits command. See 'gits --help'." suggestions=$(similar_commands "$command") || return 1 [ -n "$suggestions" ] || return 1 while IFS= read -r suggestion; do [ -n "$suggestion" ] || continue count=$((count + 1)) done <<< "$suggestions" printf '\n' >&2 if [ "$count" -eq 1 ]; then printf 'The most similar command is\n' >&2 else printf 'The most similar commands are\n' >&2 fi while IFS= read -r suggestion; do [ -n "$suggestion" ] || continue printf '\t%s\n' "$suggestion" >&2 done <<< "$suggestions" return 1 } manpage() { cat <<'EOF' .TH GITS 1 "August 12, 2026" "gits" "User Commands" .SH NAME gits \- manage Git submodules with optional project-scoped object sharing .SH SYNOPSIS .nf \fBgits\fR [\fB\-C\fR \fIpath\fR] \fIcommand\fR [\fIargs\fR] \fBgits\fR [\fB\-C\fR \fIpath\fR] \fBhelp\fR [\fIcommand\fR] \fBgits\fR [\fB\-C\fR \fIpath\fR] [\fB\-h\fR | \fB\-\-help\fR] \fBgits\fR [\fB\-C\fR \fIpath\fR] [\fB\-v\fR | \fB\-\-version\fR] .fi .SH DESCRIPTION .B gits is a focused workflow around Git submodules. It preserves independent submodule checkouts while optionally allowing projects to borrow Git objects from a shared set of bare mirrors. .PP Shared mode is explicit and project scoped. It is enabled only when .B gits init or .B gits config receives a shared path. The canonical path is stored in the current repository's local Git config; no global gits configuration enables object sharing implicitly. .PP Commands that inspect or change submodules require the current directory to be inside a Git repository. Help, version, completion generation, and manual-page generation do not. .SH OPTIONS .TP \fB\-C\fR \fIpath\fR Run as if gits was started in .I path instead of the current working directory. The option is processed before command dispatch and may be used with any command. .SH COMMANDS .TP .B init [shared-path] Synchronize and initialize submodules. Without a path, use standard Git submodule storage. With a path, enable project-scoped object sharing at the canonical location before initialization. .TP .B pull [path...] [--all] Fast-forward the superproject with recursive submodule checkout disabled, then fast-forward selected initialized top-level submodules on their current branches. Without paths, update all declared top-level submodules. .B --all is an explicit equivalent. .TP .B reset [--hard] [path...] [--all] Without .BR --hard , unstage changes in the superproject and selected submodules. With .BR --hard , discard tracked changes and restore recorded submodule commits. Without paths, process the repository and every declared submodule. .TP .B add args... Pass remaining arguments to .BR "git submodule add" . .TP .B admit path... Stage selected paths, open the Git-configured editor with a context-aware default message, and commit only those paths outside a merge. .TP .B admit --all Stage every submodule declared in .I .gitmodules and create a commit. Unlike .BR "git add --all" , this does not select unrelated files. .TP .B config Show the shared path configured for the current project. .TP .B config shared-path Enable or change project-scoped shared mode. .TP .B config --unset Disable shared mode and remove gits-managed alternate references from the current project. Central mirrors and unrelated user-managed alternates are preserved. .TP .B list Show the project's shared mode and every declared submodule path, URL, and cache state. .TP .B cleanup [--apply] Scan registered roots and delete complete bare mirrors that have remained unused for at least 30 days. With no option, .B cleanup is equivalent to .BR "cleanup --apply" . .TP .B cleanup --dry-run Scan and report mirror state without deleting anything. .TP .B cleanup --list List registered project scan roots. .TP .B cleanup --append root [--append root...] Register one or more directory trees that may contain consumers of the current shared repository. This action does not scan or delete mirrors. .TP .B cleanup --remove root Remove one registered scan root without deleting mirrors. .TP .B status args... Pass remaining arguments to .BR "git submodule status" . .TP .B update args... Pass remaining arguments to .BR "git submodule update" . .TP .B deinit args... Pass remaining arguments to .BR "git submodule deinit" . .TP .B foreach args... Pass remaining arguments to .BR "git submodule foreach" . .TP .B summary args... Pass remaining arguments to .BR "git submodule summary" . .TP .B sync args... Pass remaining arguments to .BR "git submodule sync" . .TP .B set-branch args... Pass remaining arguments to .BR "git submodule set-branch" . .TP .B set-url args... Pass remaining arguments to .BR "git submodule set-url" . .TP .B absorbgitdirs args... Pass remaining arguments to .BR "git submodule absorbgitdirs" . .TP .B help [command] Show the root command overview or detailed help for one command. For example, .B gits help cleanup is equivalent to .BR "gits cleanup --help" . .TP .B version Print the installed gits version, release date, and project homepage. .SH SHARED OBJECTS The shared directory contains one bare mirror for each normalized submodule URL. Each project retains its own submodule working tree and borrows the mirror's object database through .IR objects/info/alternates . gits does not replace working trees with symbolic links. .PP URLs that differ only by a trailing .B .git suffix use the same mirror. When multiple submodule paths use one repository URL, shared pull fetches the mirror once and updates each selected checkout independently. .PP Shared init, pull, and cleanup operations use one lock under the shared directory. If another operation owns the lock, the new operation fails without modifying the shared repository. .SH PULL BEHAVIOR .B gits pull first runs a fast-forward-only superproject pull with recursive submodule checkout disabled. If the current superproject branch has no upstream and .B origin contains a same-named branch, gits configures that remote branch as the upstream before pulling. It does not create or push a missing remote branch. It then synchronizes submodule configuration and processes the selected top-level paths. .PP An initialized submodule remains on its current branch and is fast-forwarded to that branch's configured upstream. If no upstream is configured, gits tries the same-named branch on .BR origin . A detached checkout or missing remote branch is reported without choosing a different branch. Other selected submodules are still processed, and the command returns failure after traversal if any update failed. .PP An uninitialized selected submodule is initialized at the commit recorded by the superproject without choosing a branch. Updated gitlinks remain visible as superproject changes until committed. .SH ADMIT BEHAVIOR Before opening the editor, .B admit saves the current index and stages the requested paths. If the selected staged changes contain only submodule entries, the initial message is .BR "update submodule: paths" . If regular files are included, the initial message is .BR "feat:" . .PP Outside a merge, changes staged for unrelated paths remain staged for a later commit. During a merge, Git commits the complete resolved index. If editing is interrupted, the message is empty, unresolved paths remain, or commit creation fails, gits restores the index to its pre-command state. Exiting the editor without saving the message also restores the index, even when the editor exits successfully. Working tree changes are never discarded by this rollback. .SH CLEANUP SAFETY Cleanup operates at whole-mirror granularity. It does not prune individual objects from a referenced mirror because active checkouts may borrow objects that are no longer reachable from current remote references. .PP Every project that may consume the shared repository must be located under a registered scan root. After a complete scan first finds a mirror unused, the mirror enters a 30-day waiting period. Only a later complete apply scan can delete it. .PP Cleanup fails closed and deletes nothing when a registered root is missing or unreadable, an alternate is invalid, the shared repository is locked, a mirror is a symbolic link or not a normal bare repository, or mirror ownership cannot be verified. A regular .I .DS_Store directly inside the repositories directory is ignored; other unexpected entries block deletion. .SH FILES .TP .I .git/config Stores .B gits.sharedSubmodules for the current project when shared mode is enabled. .TP .I shared-path/repositories Contains normalized bare mirrors used by configured projects. .TP .I shared-path/.gits/lock Serializes shared init, pull, and cleanup operations. .TP .I shared-path/.gits/clean-config Stores cleanup scan roots, candidates, and observation metadata. .TP .I submodule-git-dir/objects/info/alternates References borrowed object databases. gits preserves unrelated user-managed entries. .SH ENVIRONMENT .TP .B GIT_EDITOR Editor used by .BR "gits admit" , subject to normal Git editor precedence. .SH SAFETY Shared mode reduces duplicate object storage; it does not merge project working trees or change the commits recorded by the superproject automatically. .PP .B gits reset --hard discards tracked changes in its selected scope. Review the paths before running it. .PP Cleanup safety depends on complete scan-root registration. A consumer outside every registered root cannot be discovered and must be covered by another .B cleanup --append root before apply. .SH EXIT STATUS .TP .B 0 Success or displayed help. .TP .B 1 Invalid syntax, unknown command, missing repository, Git failure, validation failure, or incomplete cleanup scan. .SH EXAMPLES .nf gits init gits pull gits admit scripts gits -C /path/to/workspace/services admit --all gits init ~/.cache/gits gits list gits cleanup --append ~/Code gits cleanup --dry-run gits cleanup gits help cleanup .fi .SH SEE ALSO .BR git (1), .BR git-submodule (1), .BR brew (1) EOF } completion_submodule_paths() { local mode="$1" local root local prefix local key local path root=$(git_root) || return 0 prefix=$(git rev-parse --show-prefix 2>/dev/null) || return 0 while IFS= read -r -d '' key && IFS= read -r -d '' path; do [ -n "$key" ] || continue case "$mode" in root) printf '%s\n' "$path" ;; cwd) if [ -z "$prefix" ]; then printf '%s\n' "$path" elif [[ "$path" == "$prefix"* ]]; then printf '%s\n' "${path#"$prefix"}" fi ;; esac done < <(submodule_entries "$root") } completion_changed_paths() { local mode="$1" local root local prefix local record local status local path root=$(git_root) || return 0 prefix=$(git rev-parse --show-prefix 2>/dev/null) || return 0 while IFS= read -r -d '' record; do status=${record:0:2} path=${record:3} if [[ "$status" == *R* ]] || [[ "$status" == *C* ]]; then IFS= read -r -d '' record || true fi case "$mode" in root) printf '%s\n' "$path" ;; cwd) if [ -z "$prefix" ]; then printf '%s\n' "$path" elif [[ "$path" == "$prefix"* ]]; then printf '%s\n' "${path#"$prefix"}" fi ;; esac done < <(git -C "$root" status --porcelain=v1 -z --untracked-files=all --ignore-submodules=none 2>/dev/null) } completion_script() { case "$1" in bash) cat <<'EOF' _gits_complete_paths() { local path_type="$1" local path_prefix="$2" local candidate while IFS= read -r candidate; do COMPREPLY+=( "$candidate" ) done < <(compgen "$path_type" -- "$path_prefix") } _gits_complete_dynamic_paths() { local action="$1" local mode="$2" local path_prefix="$3" local candidate local existing local index=1 local -a global_args=() while [ "$index" -lt "$command_index" ] && [ "${COMP_WORDS[$index]}" = "-C" ]; do global_args+=( -C "${COMP_WORDS[$((index + 1))]}" ) index=$((index + 2)) done while IFS= read -r candidate; do [[ "$candidate" == "$path_prefix"* ]] || continue for existing in "${COMPREPLY[@]}"; do [ "$existing" = "$candidate" ] && continue 2 done COMPREPLY+=( "$candidate" ) done < <(command gits "${global_args[@]}" "$action" "$mode" 2>/dev/null) } _gits() { local cur prev subcommand command_index COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" prev="" if [ "$COMP_CWORD" -gt 0 ]; then prev="${COMP_WORDS[COMP_CWORD-1]}" fi if [ "$prev" = "-C" ]; then _gits_complete_paths -d "$cur" return 0 fi command_index=1 while [ "$command_index" -lt "$COMP_CWORD" ] && [ "${COMP_WORDS[$command_index]}" = "-C" ]; do command_index=$((command_index + 2)) done if [ "$COMP_CWORD" -eq "$command_index" ]; then COMPREPLY=( $(compgen -W "-C init pull reset add admit config list cleanup status update deinit foreach summary sync set-branch set-url absorbgitdirs help version --help -h --version -v" -- "$cur") ) return 0 fi subcommand="${COMP_WORDS[$command_index]}" case "$subcommand" in help) if [ "$COMP_CWORD" -eq $((command_index + 1)) ]; then COMPREPLY=( $(compgen -W "init pull reset add admit config list cleanup status update deinit foreach summary sync set-branch set-url absorbgitdirs help version" -- "$cur") ) fi ;; admit) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "--all" -- "$cur") ) else _gits_complete_dynamic_paths __complete-changed-paths cwd "$cur" fi ;; pull) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "--all" -- "$cur") ) else _gits_complete_dynamic_paths __complete-submodules root "$cur" fi ;; cleanup) case "$prev" in --append|--remove) _gits_complete_paths -d "$cur" ;; *) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "--apply --dry-run --list --append --remove" -- "$cur") ) fi ;; esac ;; reset) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "--hard --all" -- "$cur") ) else _gits_complete_dynamic_paths __complete-submodules root "$cur" fi ;; config) if [ "$prev" != "--unset" ]; then COMPREPLY=( $(compgen -W "--unset" -- "$cur") ) _gits_complete_paths -d "$cur" fi ;; init) _gits_complete_paths -d "$cur" ;; add) case "$prev" in --reference) _gits_complete_paths -d "$cur" ;; -b|--branch|--name|--depth) ;; *) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "-q --quiet -b --branch -f --force --name --reference --dissociate --depth --progress --no-progress --" -- "$cur") ) else _gits_complete_paths -f "$cur" fi ;; esac ;; status) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "-q --quiet --cached --recursive" -- "$cur") ) else _gits_complete_dynamic_paths __complete-submodules root "$cur" fi ;; update) case "$prev" in --reference) _gits_complete_paths -d "$cur" ;; --filter) ;; *) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "--init --filter --remote -N --no-fetch -f --force --checkout --merge --rebase --recommend-shallow --no-recommend-shallow --reference --recursive --single-branch --no-single-branch --" -- "$cur") ) else _gits_complete_dynamic_paths __complete-submodules root "$cur" fi ;; esac ;; deinit) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "-f --force --all --" -- "$cur") ) else _gits_complete_dynamic_paths __complete-submodules root "$cur" fi ;; foreach) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "--recursive" -- "$cur") ) fi ;; summary) if [ "$prev" != "--summary-limit" ]; then if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "--cached --files --summary-limit" -- "$cur") ) else _gits_complete_dynamic_paths __complete-submodules root "$cur" fi fi ;; sync) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "--recursive" -- "$cur") ) else _gits_complete_dynamic_paths __complete-submodules root "$cur" fi ;; set-branch) if [ "$prev" != "--branch" ]; then if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "--default --branch --" -- "$cur") ) else _gits_complete_dynamic_paths __complete-submodules root "$cur" fi fi ;; set-url|absorbgitdirs) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "--" -- "$cur") ) else _gits_complete_dynamic_paths __complete-submodules root "$cur" fi ;; esac } complete -o filenames -F _gits gits EOF ;; zsh) cat <<'EOF' #compdef gits _gits_dynamic_paths() { local action="$1" local mode="$2" local -a candidates candidates=("${(@f)$(command gits "${gits_global_args[@]}" "$action" "$mode" 2>/dev/null)}") (( ${#candidates} )) && _describe 'path' candidates } _gits() { local -a commands local -a gits_global_args local state local subcommand commands=( 'init:initialize submodules' 'pull:fast-forward the repository and update submodules' 'reset:reset the repository and its submodules' 'add:pass through to git submodule add' 'admit:stage changes, edit a message, and commit' 'config:show, enable, or disable shared mode' 'list:show submodules and shared repository state' 'cleanup:remove or preview unused shared mirrors' 'status:pass through to git submodule status' 'update:pass through to git submodule update' 'deinit:pass through to git submodule deinit' 'foreach:pass through to git submodule foreach' 'summary:pass through to git submodule summary' 'sync:pass through to git submodule sync' 'set-branch:pass through to git submodule set-branch' 'set-url:pass through to git submodule set-url' 'absorbgitdirs:pass through to git submodule absorbgitdirs' 'help:show help' 'version:show the installed version' '--help:show help' '-h:show help' '--version:show the installed version' '-v:show the installed version' '-C:run as if gits was started in a directory' ) local command_index=2 while [[ "$words[$command_index]" == "-C" ]]; do if (( CURRENT == command_index + 1 )); then _directories return fi gits_global_args+=( -C "$words[$((command_index + 1))]" ) (( command_index += 2 )) done if (( CURRENT == command_index )); then _describe 'command' commands return fi subcommand="$words[$command_index]" words=("${(@)words[$command_index,-1]}") CURRENT=$((CURRENT - command_index + 1)) case "$subcommand" in help) _arguments '1:command:(init pull reset add admit config list cleanup status update deinit foreach summary sync set-branch set-url absorbgitdirs help version)' ;; admit) _arguments '--all[stage every declared submodule]' '*:path:->admit_paths' if [[ "$state" == "admit_paths" ]]; then _gits_dynamic_paths __complete-changed-paths cwd fi ;; pull) _arguments '--all[update every declared submodule]' '*:submodule path:->submodules' [[ "$state" == "submodules" ]] && _gits_dynamic_paths __complete-submodules root ;; cleanup) _arguments \ '--apply[delete mirrors unused for at least 30 days]' \ '--dry-run[preview without deleting mirrors]' \ '--list[list registered scan roots]' \ '*--append[register a project scan root]:directory:_directories' \ '--remove[remove a registered scan root]:directory:_directories' ;; reset) _arguments \ '--hard[discard working tree changes]' \ '--all[reset the repository and every declared submodule]' \ '*:submodule path:->submodules' [[ "$state" == "submodules" ]] && _gits_dynamic_paths __complete-submodules root ;; config) _arguments '--unset[disable shared mode]' '1:shared directory:_directories' ;; init) _arguments '1:shared directory:_directories' ;; add) _arguments \ '(-q --quiet)'{-q,--quiet}'[suppress progress output]' \ '(-b --branch)'{-b,--branch}'[checkout branch]:branch:' \ '(-f --force)'{-f,--force}'[allow an otherwise ignored path]' \ '--name[set the submodule name]:name:' \ '--reference[reference repository]:repository:_directories' \ '--dissociate[borrow objects only during clone]' \ '--depth[create a shallow clone]:depth:' \ '--progress[force progress reporting]' \ '--no-progress[disable progress reporting]' \ '--[end option parsing]' \ '1:repository:_files' \ '2:path:_files' ;; status) _arguments \ '(-q --quiet)'{-q,--quiet}'[suppress progress output]' \ '--cached[use the commit recorded in the index]' \ '--recursive[recurse into nested submodules]' \ '*:submodule path:->submodules' [[ "$state" == "submodules" ]] && _gits_dynamic_paths __complete-submodules root ;; update) _arguments \ '--init[initialize unregistered submodules]' \ '--filter[apply a partial clone filter]:filter:' \ '--remote[use the remote tracking branch]' \ '(-N --no-fetch)'{-N,--no-fetch}'[do not fetch new objects]' \ '(-f --force)'{-f,--force}'[force checkout updates]' \ '(--checkout --merge --rebase)--checkout[checkout recorded commits]' \ '(--checkout --merge --rebase)--merge[merge recorded commits]' \ '(--checkout --merge --rebase)--rebase[rebase onto recorded commits]' \ '(--recommend-shallow --no-recommend-shallow)--recommend-shallow[use recommended shallow setting]' \ '(--recommend-shallow --no-recommend-shallow)--no-recommend-shallow[ignore recommended shallow setting]' \ '--reference[reference repository]:repository:_directories' \ '--recursive[recurse into nested submodules]' \ '(--single-branch --no-single-branch)--single-branch[clone one branch]' \ '(--single-branch --no-single-branch)--no-single-branch[clone all branches]' \ '*:submodule path:->submodules' [[ "$state" == "submodules" ]] && _gits_dynamic_paths __complete-submodules root ;; deinit) _arguments \ '(-f --force)'{-f,--force}'[remove local changes]' \ '--all[deinitialize every submodule]' \ '*:submodule path:->submodules' [[ "$state" == "submodules" ]] && _gits_dynamic_paths __complete-submodules root ;; foreach) _arguments '--recursive[recurse into nested submodules]' '*:command:' ;; summary) _arguments \ '(--cached --files)--cached[compare the index with the commit]' \ '(--cached --files)--files[compare the working tree with the index]' \ '--summary-limit[limit the number of commits]:count:' \ '*:submodule path:->submodules' [[ "$state" == "submodules" ]] && _gits_dynamic_paths __complete-submodules root ;; sync) _arguments '--recursive[recurse into nested submodules]' '*:submodule path:->submodules' [[ "$state" == "submodules" ]] && _gits_dynamic_paths __complete-submodules root ;; set-branch) _arguments \ '(--default --branch)--default[remove the configured branch]' \ '(--default --branch)--branch[set the tracked branch]:branch:' \ '1:submodule path:->submodules' [[ "$state" == "submodules" ]] && _gits_dynamic_paths __complete-submodules root ;; set-url) _arguments '1:submodule path:->submodules' '2:new URL:' [[ "$state" == "submodules" ]] && _gits_dynamic_paths __complete-submodules root ;; absorbgitdirs) _arguments '*:submodule path:->submodules' [[ "$state" == "submodules" ]] && _gits_dynamic_paths __complete-submodules root ;; esac } compdef _gits gits EOF ;; fish) cat <<'EOF' function __gits_no_subcommand set -l words (commandline -opc) set -e words[1] while test (count $words) -ge 2; and test "$words[1]" = -C set -e words[1..2] end test (count $words) -eq 0 end function __gits_complete_dynamic_paths set -l action $argv[1] set -l mode $argv[2] set -l words (commandline -opc) set -l global_args set -e words[1] while test (count $words) -ge 2; and test "$words[1]" = -C set -a global_args -C $words[2] set -e words[1..2] end command gits $global_args $action $mode 2>/dev/null end complete -c gits -f -n __gits_no_subcommand -a init -d 'Initialize submodules' complete -c gits -f -n __gits_no_subcommand -a pull -d 'Fast-forward the repository and update submodules' complete -c gits -f -n __gits_no_subcommand -a reset -d 'Reset the repository and its submodules' complete -c gits -f -n __gits_no_subcommand -a add -d 'Pass through to git submodule add' complete -c gits -f -n __gits_no_subcommand -a admit -d 'Stage changes, edit a message, and commit' complete -c gits -f -n __gits_no_subcommand -a config -d 'Show, enable, or disable shared mode' complete -c gits -f -n __gits_no_subcommand -a list -d 'Show submodules and shared repository state' complete -c gits -f -n __gits_no_subcommand -a cleanup -d 'Remove or preview unused shared mirrors' complete -c gits -f -n __gits_no_subcommand -a 'status update deinit foreach summary sync set-branch set-url absorbgitdirs' -d 'Pass through to git submodule' complete -c gits -f -n __gits_no_subcommand -a help -d 'Show help' complete -c gits -f -n __gits_no_subcommand -a version -d 'Show the installed version' complete -c gits -n __gits_no_subcommand -s h -l help -d 'Show help' complete -c gits -n __gits_no_subcommand -s v -l version -d 'Show the installed version' complete -c gits -n __gits_no_subcommand -s C -r -a '(__fish_complete_directories)' -d 'Run as if gits was started in a directory' complete -c gits -f -n '__fish_seen_subcommand_from help' -a 'init pull reset add admit config list cleanup status update deinit foreach summary sync set-branch set-url absorbgitdirs help version' -d 'Show command help' complete -c gits -n '__fish_seen_subcommand_from admit' -l all -d 'Stage every declared submodule' complete -c gits -f -n '__fish_seen_subcommand_from admit' -a '(__gits_complete_dynamic_paths __complete-changed-paths cwd)' -d 'Changed path' complete -c gits -n '__fish_seen_subcommand_from pull' -l all -d 'Update every declared submodule' complete -c gits -f -n '__fish_seen_subcommand_from pull' -a '(__gits_complete_dynamic_paths __complete-submodules root)' -d 'Submodule' complete -c gits -n '__fish_seen_subcommand_from cleanup' -l apply -d 'Delete mirrors unused for at least 30 days' complete -c gits -n '__fish_seen_subcommand_from cleanup' -l dry-run -d 'Preview without deleting mirrors' complete -c gits -n '__fish_seen_subcommand_from cleanup' -l list -d 'List registered scan roots' complete -c gits -n '__fish_seen_subcommand_from cleanup' -l append -r -a '(__fish_complete_directories)' -d 'Register a project scan root' complete -c gits -n '__fish_seen_subcommand_from cleanup' -l remove -r -a '(__fish_complete_directories)' -d 'Remove a registered scan root' complete -c gits -n '__fish_seen_subcommand_from reset' -l hard -d 'Discard working tree changes' complete -c gits -n '__fish_seen_subcommand_from reset' -l all -d 'Reset the repository and every declared submodule' complete -c gits -f -n '__fish_seen_subcommand_from reset' -a '(__gits_complete_dynamic_paths __complete-submodules root)' -d 'Submodule' complete -c gits -n '__fish_seen_subcommand_from config' -l unset -d 'Disable shared mode' complete -c gits -F -n '__fish_seen_subcommand_from config init' complete -c gits -n '__fish_seen_subcommand_from add' -s q -l quiet -d 'Suppress progress output' complete -c gits -n '__fish_seen_subcommand_from add' -s b -l branch -r -d 'Checkout branch' complete -c gits -n '__fish_seen_subcommand_from add' -s f -l force -d 'Allow an otherwise ignored path' complete -c gits -n '__fish_seen_subcommand_from add' -l name -r -d 'Set the submodule name' complete -c gits -n '__fish_seen_subcommand_from add' -l reference -r -a '(__fish_complete_directories)' -d 'Reference repository' complete -c gits -n '__fish_seen_subcommand_from add' -l dissociate -d 'Borrow objects only during clone' complete -c gits -n '__fish_seen_subcommand_from add' -l depth -r -d 'Create a shallow clone' complete -c gits -n '__fish_seen_subcommand_from add' -l progress -d 'Force progress reporting' complete -c gits -n '__fish_seen_subcommand_from add' -l no-progress -d 'Disable progress reporting' complete -c gits -F -n '__fish_seen_subcommand_from add' complete -c gits -n '__fish_seen_subcommand_from status' -s q -l quiet -d 'Suppress progress output' complete -c gits -n '__fish_seen_subcommand_from status' -l cached -d 'Use the commit recorded in the index' complete -c gits -n '__fish_seen_subcommand_from status' -l recursive -d 'Recurse into nested submodules' complete -c gits -f -n '__fish_seen_subcommand_from status' -a '(__gits_complete_dynamic_paths __complete-submodules root)' -d 'Submodule' complete -c gits -n '__fish_seen_subcommand_from update' -l init -d 'Initialize unregistered submodules' complete -c gits -n '__fish_seen_subcommand_from update' -l filter -r -d 'Apply a partial clone filter' complete -c gits -n '__fish_seen_subcommand_from update' -l remote -d 'Use the remote tracking branch' complete -c gits -n '__fish_seen_subcommand_from update' -s N -l no-fetch -d 'Do not fetch new objects' complete -c gits -n '__fish_seen_subcommand_from update' -s f -l force -d 'Force checkout updates' complete -c gits -n '__fish_seen_subcommand_from update' -l checkout -d 'Checkout recorded commits' complete -c gits -n '__fish_seen_subcommand_from update' -l merge -d 'Merge recorded commits' complete -c gits -n '__fish_seen_subcommand_from update' -l rebase -d 'Rebase onto recorded commits' complete -c gits -n '__fish_seen_subcommand_from update' -l recommend-shallow -d 'Use recommended shallow setting' complete -c gits -n '__fish_seen_subcommand_from update' -l no-recommend-shallow -d 'Ignore recommended shallow setting' complete -c gits -n '__fish_seen_subcommand_from update' -l reference -r -a '(__fish_complete_directories)' -d 'Reference repository' complete -c gits -n '__fish_seen_subcommand_from update' -l recursive -d 'Recurse into nested submodules' complete -c gits -n '__fish_seen_subcommand_from update' -l single-branch -d 'Clone one branch' complete -c gits -n '__fish_seen_subcommand_from update' -l no-single-branch -d 'Clone all branches' complete -c gits -f -n '__fish_seen_subcommand_from update' -a '(__gits_complete_dynamic_paths __complete-submodules root)' -d 'Submodule' complete -c gits -n '__fish_seen_subcommand_from deinit' -s f -l force -d 'Remove local changes' complete -c gits -n '__fish_seen_subcommand_from deinit' -l all -d 'Deinitialize every submodule' complete -c gits -f -n '__fish_seen_subcommand_from deinit' -a '(__gits_complete_dynamic_paths __complete-submodules root)' -d 'Submodule' complete -c gits -n '__fish_seen_subcommand_from foreach sync' -l recursive -d 'Recurse into nested submodules' complete -c gits -n '__fish_seen_subcommand_from summary' -l cached -d 'Compare the index with the commit' complete -c gits -n '__fish_seen_subcommand_from summary' -l files -d 'Compare the working tree with the index' complete -c gits -n '__fish_seen_subcommand_from summary' -l summary-limit -r -d 'Limit the number of commits' complete -c gits -n '__fish_seen_subcommand_from set-branch' -l default -d 'Remove the configured branch' complete -c gits -n '__fish_seen_subcommand_from set-branch' -l branch -r -d 'Set the tracked branch' complete -c gits -f -n '__fish_seen_subcommand_from summary sync set-branch set-url absorbgitdirs' -a '(__gits_complete_dynamic_paths __complete-submodules root)' -d 'Submodule' EOF ;; *) error "unsupported completion shell '$1'; expected bash, zsh, or fish" return 1 ;; esac } shared_base() { local root="$1" git -C "$root" config --local --get "$CONFIG_KEY" 2>/dev/null } canonical_directory() { local path="$1" mkdir -p "$path" || return 1 ( cd "$path" || exit 1 pwd -P ) } canonical_existing_directory() { local path="$1" [ -d "$path" ] || return 1 ( cd "$path" || exit 1 pwd -P ) } acquire_shared_lock() { local base="$1" local lock="$base/.gits/lock" local owner mkdir -p "$base/.gits" || return 1 if ! mkdir "$lock" 2>/dev/null; then owner=$(sed -n '1p' "$lock/pid" 2>/dev/null) if [ -n "$owner" ]; then error "shared modules repository is locked by process $owner: $lock" else error "shared modules repository is locked: $lock" fi return 1 fi printf '%s\n' "$$" > "$lock/pid" || { rmdir "$lock" 2>/dev/null || true return 1 } SHARED_LOCK_DIR="$lock" } unlock_shared() { local base="$1" local requested="$2" local lock="$base/.gits/lock" local owner command attempt [ -d "$lock" ] && [ ! -L "$lock" ] || { error "no shared lock directory: $lock" return 1 } owner=$(sed -n '1p' "$lock/pid" 2>/dev/null) case "$owner" in ''|*[!0-9]*|0|1) error "invalid or missing lock owner: $lock/pid" return 1 ;; esac if [ -n "$requested" ] && [ "$requested" != "$owner" ]; then error "lock owner is $owner, not $requested" return 1 fi if kill -0 "$owner" 2>/dev/null; then [ "$requested" = "$owner" ] || { error "lock owner is running; use gits unlock --terminate $owner" return 1 } command=$(ps -p "$owner" -o args=) || return 1 case "$command" in *'gits '*) ;; *) error "refusing to terminate a non-gits process: $owner"; return 1 ;; esac kill -TERM "$owner" || return 1 for attempt in 1 2 3 4 5; do kill -0 "$owner" 2>/dev/null || break sleep 1 done if kill -0 "$owner" 2>/dev/null; then error "process $owner is still running; lock retained" return 1 fi fi [ -d "$lock" ] || return 0 [ "$(sed -n '1p' "$lock/pid" 2>/dev/null)" = "$owner" ] || { error "lock owner changed; lock retained" return 1 } rm -- "$lock/pid" && rmdir -- "$lock" || return 1 printf 'Released shared lock: %s\n' "$lock" } release_shared_lock() { [ -n "$SHARED_LOCK_DIR" ] || return 0 rm -f "$SHARED_LOCK_DIR/pid" rmdir "$SHARED_LOCK_DIR" 2>/dev/null || true SHARED_LOCK_DIR="" } run_with_shared_lock() { local base="$1" local status shift acquire_shared_lock "$base" || return 1 "$@" status=$? release_shared_lock return "$status" } enable_shared() { local root="$1" local requested_path="$2" local path path=$(canonical_directory "$requested_path") || return 1 git -C "$root" config --local "$CONFIG_KEY" "$path" || return 1 printf 'shared modules repository enabled for this project: %s%s%s\n' "$GREEN" "$path" "$RESET" } disable_shared() { local root="$1" local key local path local name local url local mirror local legacy_mirror while IFS= read -r -d '' key && IFS= read -r -d '' path; do [ -n "$key" ] || continue git -C "$root/$path" rev-parse --git-dir >/dev/null 2>&1 || continue name=${key#submodule.} name=${name%.path} url=$(git -C "$root" config --local --get "submodule.$name.url" 2>/dev/null) [ -n "$url" ] || url=$(git config --file "$root/.gitmodules" --get "submodule.$name.url" 2>/dev/null) [ -n "$url" ] || continue mirror=$(mirror_path "." "$url") || return 1 legacy_mirror=$(legacy_mirror_path "." "$url") || return 1 remove_alternate "$root/$path" "$mirror" "$legacy_mirror" || return 1 attach_submodule_branch "$root" "$path" "$name" || return 1 done < <(submodule_entries "$root") git -C "$root" config --local --unset-all "$CONFIG_KEY" 2>/dev/null || true printf 'shared modules repository for this project: %sDisabled%s\n' "$RED" "$RESET" } submodule_entries() { local root="$1" local entry local key local path [ -f "$root/.gitmodules" ] || return 0 while IFS= read -r -d '' entry; do key=${entry%%$'\n'*} path=${entry#*$'\n'} printf '%s\0%s\0' "$key" "$path" done < <(git config -z --file "$root/.gitmodules" --get-regexp 'submodule\..*\.path' 2>/dev/null) } normal_init() { local root="$1" local key local path local name print_cmd "git submodule sync --recursive" git -C "$root" submodule sync --recursive || return 1 # print_cmd "git submodule update --init --recursive" git -C "$root" submodule update --init --recursive || return 1 while IFS= read -r -d '' key && IFS= read -r -d '' path; do [ -n "$key" ] || continue name=${key#submodule.} name=${name%.path} attach_submodule_branch "$root" "$path" "$name" || return 1 done < <(submodule_entries "$root") } configure_available_superproject_upstream() { local root="$1" local branch local upstream local remote_ref branch=$(git -C "$root" symbolic-ref --short -q HEAD) [ -n "$branch" ] || return 0 upstream=$(git -C "$root" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null) [ -z "$upstream" ] || return 0 git -C "$root" remote get-url origin >/dev/null 2>&1 || return 0 git -C "$root" ls-remote --exit-code --heads origin "refs/heads/$branch" >/dev/null 2>&1 || return 0 remote_ref="refs/remotes/origin/$branch" if ! git -C "$root" show-ref --verify --quiet "$remote_ref"; then print_cmd "git fetch --no-recurse-submodules origin +refs/heads/$branch:$remote_ref" git -C "$root" fetch --no-recurse-submodules origin "+refs/heads/$branch:$remote_ref" || return 1 fi print_cmd "git branch --set-upstream-to=origin/$branch $branch" git -C "$root" branch --set-upstream-to="origin/$branch" "$branch" } pull_superproject() { local root="$1" configure_available_superproject_upstream "$root" || return 1 print_cmd "git pull --ff-only --no-recurse-submodules" git -C "$root" -c fetch.recurseSubmodules=false pull --ff-only --no-recurse-submodules } normal_pull() { local root="$1" shift local key local path local status=0 pull_superproject "$root" || return 1 validate_submodule_paths "$root" "$@" || return 1 # print_cmd "git submodule sync --recursive" git -C "$root" submodule sync --recursive || return 1 while IFS= read -r -d '' key && IFS= read -r -d '' path; do [ -n "$key" ] || continue submodule_path_selected "$path" "$@" || continue if git -C "$root/$path" rev-parse --git-dir >/dev/null 2>&1; then update_checkout_from_remote "$root" "$path" || status=1 else print_cmd "git submodule update --init -- $path" git -C "$root" submodule update --init -- "$path" || status=1 fi done < <(submodule_entries "$root") return "$status" } mirror_url_key() { local url="$1" printf '%s\n' "${url%.git}" } mirror_path_for_key() { local base="$1" local key="$2" local hash local slug hash=$(printf '%s' "$key" | git hash-object --stdin) || return 1 slug=$(basename "${key%.git}") slug=$(printf '%s' "$slug" | tr -c '[:alnum:]._-' '_') || return 1 printf '%s/repositories/%s-%s.git\n' "$base" "$slug" "${hash:0:12}" } mirror_path() { local base="$1" local url="$2" local key key=$(mirror_url_key "$url") || return 1 mirror_path_for_key "$base" "$key" } legacy_mirror_path() { local base="$1" local url="$2" local key key=$(mirror_url_key "$url") || return 1 mirror_path_for_key "$base" "$key.git" } update_mirror() { local url="$1" local mirror="$2" mkdir -p "$(dirname "$mirror")" || return 1 if [ -e "$mirror" ]; then if [ "$(git -C "$mirror" rev-parse --is-bare-repository 2>/dev/null)" != "true" ]; then error "shared modules repository path is not a bare Git repository: $mirror" return 1 fi # print_cmd "git -C $mirror remote update --prune" git -C "$mirror" remote update --prune else print_cmd "git clone --mirror $url $mirror" git clone --mirror "$url" "$mirror" fi } invalidate_commit_graph() { local checkout="$1" local info info=$(git -C "$checkout" -c core.commitGraph=false rev-parse --path-format=absolute --git-path objects/info 2>/dev/null) || return 1 # Changing object databases can leave cached commits unavailable. Git rebuilds these optional caches. rm -f "$info/commit-graph" || return 1 rm -rf "$info/commit-graphs" } add_alternate() { local checkout="$1" local mirror="$2" local legacy_mirror="${3:-$mirror}" local alternates local alternate local mirror_name local legacy_mirror_name local objects="$mirror/objects" local found=false local temp alternates=$(git -C "$checkout" rev-parse --path-format=absolute --git-path objects/info/alternates 2>/dev/null) || return 1 mkdir -p "$(dirname "$alternates")" || return 1 mirror_name=$(basename "$mirror") legacy_mirror_name=$(basename "$legacy_mirror") temp="$alternates.gits.$$" : > "$temp" || return 1 if [ -f "$alternates" ]; then while IFS= read -r alternate || [ -n "$alternate" ]; do case "$alternate" in "$objects") if [ "$found" != "true" ]; then printf '%s\n' "$alternate" >> "$temp" || return 1 found=true fi ;; */repositories/"$mirror_name"/objects|*/repositories/"$legacy_mirror_name"/objects) ;; *) printf '%s\n' "$alternate" >> "$temp" || return 1 ;; esac done < "$alternates" fi if [ "$found" != "true" ]; then printf '%s\n' "$objects" >> "$temp" || return 1 fi if [ -f "$alternates" ] && cmp -s "$temp" "$alternates"; then rm -f "$temp" return 0 fi if ! invalidate_commit_graph "$checkout"; then rm -f "$temp" error "could not invalidate commit-graph before updating alternates: $checkout" return 1 fi mv "$temp" "$alternates" } remove_alternate() { local checkout="$1" local mirror="$2" local legacy_mirror="${3:-$mirror}" local alternates local alternate local mirror_name local legacy_mirror_name local temp alternates=$(git -C "$checkout" rev-parse --path-format=absolute --git-path objects/info/alternates 2>/dev/null) || return 1 [ -f "$alternates" ] || return 0 mirror_name=$(basename "$mirror") legacy_mirror_name=$(basename "$legacy_mirror") temp="$alternates.gits.$$" : > "$temp" || return 1 while IFS= read -r alternate || [ -n "$alternate" ]; do case "$alternate" in */repositories/"$mirror_name"/objects|*/repositories/"$legacy_mirror_name"/objects) ;; *) printf '%s\n' "$alternate" >> "$temp" || return 1 ;; esac done < "$alternates" if cmp -s "$temp" "$alternates"; then rm -f "$temp" return 0 fi if ! invalidate_commit_graph "$checkout"; then rm -f "$temp" error "could not invalidate commit-graph before removing alternates: $checkout" return 1 fi if [ -s "$temp" ]; then mv "$temp" "$alternates" else rm -f "$temp" "$alternates" fi } submodule_branch() { local root="$1" local name="$2" local checkout="$3" local mirror="${4:-}" local branch branch=$(git config --file "$root/.gitmodules" --get "submodule.$name.branch" 2>/dev/null) if [ "$branch" = "." ]; then branch=$(git -C "$root" symbolic-ref --short -q HEAD) fi if [ -z "$branch" ] && [ -n "$mirror" ]; then branch=$(git -C "$mirror" symbolic-ref --short -q HEAD) fi [ -n "$branch" ] || branch=$(git -C "$checkout" symbolic-ref --short -q refs/remotes/origin/HEAD) branch=${branch#refs/heads/} branch=${branch#refs/remotes/origin/} branch=${branch#origin/} printf '%s\n' "$branch" } attach_submodule_branch() { local root="$1" local path="$2" local name="$3" local mirror="${4:-}" local checkout="$root/$path" local branch if git -C "$checkout" symbolic-ref --short -q HEAD >/dev/null; then return 0 fi branch=$(submodule_branch "$root" "$name" "$checkout" "$mirror") if [ -z "$branch" ]; then error "cannot determine remote branch for submodule: $path" return 1 fi if git -C "$checkout" show-ref --verify --quiet "refs/heads/$branch"; then if git -C "$checkout" show-ref --verify --quiet "refs/remotes/origin/$branch" && git -C "$checkout" merge-base --is-ancestor "refs/heads/$branch" "refs/remotes/origin/$branch"; then # print_cmd "git -C $path branch -f $branch HEAD" git -C "$checkout" branch -f "$branch" HEAD || return 1 fi # print_cmd "git -C $path checkout $branch" git -C "$checkout" checkout "$branch" || return 1 else # print_cmd "git -C $path checkout -b $branch" git -C "$checkout" checkout -b "$branch" || return 1 fi if git -C "$checkout" show-ref --verify --quiet "refs/remotes/origin/$branch"; then git -C "$checkout" branch --set-upstream-to="origin/$branch" "$branch" >/dev/null || return 1 fi } update_checkout_from_remote() { local root="$1" local path="$2" local checkout="$root/$path" local branch local remote local upstream local merge_ref branch=$(git -C "$checkout" symbolic-ref --short -q HEAD) if [ -z "$branch" ]; then error "submodule is not on a branch; checkout a branch before pull: $path" return 1 fi remote=$(git -C "$checkout" config --get "branch.$branch.remote" 2>/dev/null) upstream=$(git -C "$checkout" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null) if [ -z "$remote" ] || [ -z "$upstream" ]; then remote="origin" merge_ref="refs/heads/$branch" upstream="refs/remotes/origin/$branch" print_cmd "git -C $path fetch $remote $branch" git -C "$checkout" fetch "$remote" "+$merge_ref:$upstream" || return 1 elif [ "$remote" != "." ]; then print_cmd "git -C $path fetch $remote" git -C "$checkout" fetch "$remote" || return 1 fi print_cmd "git -C $path merge --ff-only $upstream" git -C "$checkout" merge --ff-only "$upstream" } update_checkout_from_mirror() { local root="$1" local path="$2" local mirror="$3" local checkout="$root/$path" local branch local remote local merge_ref local upstream_ref branch=$(git -C "$checkout" symbolic-ref --short -q HEAD) if [ -z "$branch" ]; then error "submodule is not on a branch; checkout a branch before pull: $path" return 1 fi remote=$(git -C "$checkout" config --get "branch.$branch.remote" 2>/dev/null) merge_ref=$(git -C "$checkout" config --get "branch.$branch.merge" 2>/dev/null) upstream_ref=$(git -C "$checkout" rev-parse --symbolic-full-name '@{upstream}' 2>/dev/null) if [ -z "$remote" ] || [ -z "$merge_ref" ] || [ -z "$upstream_ref" ]; then remote="origin" merge_ref="refs/heads/$branch" upstream_ref="refs/remotes/origin/$branch" fi if [ "$remote" = "origin" ]; then if ! git -C "$mirror" show-ref --verify --quiet "$merge_ref"; then error "submodule upstream is missing from shared mirror: $path ($merge_ref)" return 1 fi print_cmd "git -C $path fetch $mirror $merge_ref" git -C "$checkout" fetch "$mirror" "+$merge_ref:$upstream_ref" || return 1 else if [ "$remote" != "." ]; then print_cmd "git -C $path fetch $remote" git -C "$checkout" fetch "$remote" || return 1 fi fi print_cmd "git -C $path merge --ff-only $upstream_ref" git -C "$checkout" merge --ff-only "$upstream_ref" } shared_update() { local root="$1" local base="$2" local update_remote="${3:-false}" if [ "$#" -ge 3 ]; then shift 3 else shift 2 fi local key local path local name local url local mirror local legacy_mirror local updated_mirror local mirror_is_updated local was_initialized local status=0 local -a updated_mirrors=() printf 'using shared modules repository: %s%s%s\n' "$GREEN" "$base" "$RESET" print_cmd "git submodule sync --recursive" git -C "$root" submodule sync --recursive || return 1 while IFS= read -r -d '' key && IFS= read -r -d '' path; do [ -n "$key" ] || continue submodule_path_selected "$path" "$@" || continue name=${key#submodule.} name=${name%.path} if ! git -C "$root" submodule init -- "$path" >/dev/null; then status=1 continue fi url=$(git -C "$root" config --local --get "submodule.$name.url") if [ -z "$url" ]; then error "missing submodule URL: $path" status=1 continue fi if ! mirror=$(mirror_path "$base" "$url"); then status=1 continue fi if ! legacy_mirror=$(legacy_mirror_path "$base" "$url"); then status=1 continue fi was_initialized=false if git -C "$root/$path" rev-parse --git-dir >/dev/null 2>&1; then was_initialized=true fi mirror_is_updated=false for updated_mirror in "${updated_mirrors[@]}"; do if [ "$updated_mirror" = "$mirror" ]; then mirror_is_updated=true break fi done if [ "$mirror_is_updated" != "true" ]; then if ! update_mirror "$url" "$mirror"; then status=1 continue fi updated_mirrors+=("$mirror") fi if [ -e "$root/$path/.git" ]; then if ! add_alternate "$root/$path" "$mirror" "$legacy_mirror"; then status=1 continue fi if [ "$update_remote" != "true" ]; then print_cmd "git submodule update --init -- $path" if ! git -C "$root" submodule update --init -- "$path"; then status=1 continue fi fi else print_cmd "git submodule update --init --reference -- $path" if ! git -C "$root" submodule update --init --reference "$mirror" -- "$path"; then status=1 continue fi if ! add_alternate "$root/$path" "$mirror" "$legacy_mirror"; then status=1 continue fi fi if [ "$update_remote" = "true" ] && [ "$was_initialized" = "true" ]; then update_checkout_from_mirror "$root" "$path" "$mirror" || status=1 elif [ "$update_remote" = "true" ]; then : else if ! attach_submodule_branch "$root" "$path" "$name" "$mirror"; then status=1 continue fi if ! git -C "$root/$path" submodule update --init --recursive; then status=1 continue fi fi done < <(submodule_entries "$root") return "$status" } shared_pull() { local root="$1" local base="$2" shift 2 pull_superproject "$root" || return 1 validate_submodule_paths "$root" "$@" || return 1 shared_update "$root" "$base" true "$@" } reset_repository() { local root="$1" local mode="$2" shift 2 local key local path local name local requested local -a paths=() validate_submodule_paths "$root" "$@" || return 1 for requested in "$@"; do paths+=("$(normalize_submodule_path "$requested")") done if [ "$mode" = "--hard" ]; then if [ "${#paths[@]}" -gt 0 ]; then print_cmd "git reset -- ${paths[*]}" git -C "$root" reset -- "${paths[@]}" || return 1 for path in "${paths[@]}"; do [ -e "$root/$path/.git" ] || continue print_cmd "git -C $path reset --hard" git -C "$root/$path" reset --hard || return 1 git -C "$root/$path" submodule foreach --recursive git reset --hard || return 1 done print_cmd "git submodule update --init --recursive --force -- ${paths[*]}" git -C "$root" submodule update --init --recursive --force -- "${paths[@]}" || return 1 else print_cmd "git reset --hard" git -C "$root" reset --hard || return 1 print_cmd "git submodule foreach --recursive git reset --hard" git -C "$root" submodule foreach --recursive git reset --hard || return 1 # print_cmd "git submodule update --init --recursive --force" git -C "$root" submodule update --init --recursive --force || return 1 fi else if [ "${#paths[@]}" -gt 0 ]; then print_cmd "git reset -- ${paths[*]}" git -C "$root" reset -- "${paths[@]}" || return 1 for path in "${paths[@]}"; do [ -e "$root/$path/.git" ] || continue print_cmd "git -C $path reset" git -C "$root/$path" reset || return 1 git -C "$root/$path" submodule foreach --recursive git reset || return 1 done else print_cmd "git reset" git -C "$root" reset || return 1 print_cmd "git submodule foreach --recursive git reset" git -C "$root" submodule foreach --recursive git reset || return 1 fi fi while IFS= read -r -d '' key && IFS= read -r -d '' path; do [ -n "$key" ] || continue submodule_path_selected "$path" "$@" || continue [ -e "$root/$path/.git" ] || continue name=${key#submodule.} name=${name%.path} attach_submodule_branch "$root" "$path" "$name" || return 1 done < <(submodule_entries "$root") git -C "$root" status --short } is_submodule_path() { local root="$1" local candidate="$2" local key local path while IFS= read -r -d '' key && IFS= read -r -d '' path; do [ "$path" = "$candidate" ] && return 0 done < <(submodule_entries "$root") return 1 } normalize_submodule_path() { local candidate="$1" while [ "$candidate" != "/" ] && [ "${candidate%/}" != "$candidate" ]; do candidate=${candidate%/} done printf '%s\n' "$candidate" } validate_submodule_paths() { local root="$1" local requested local candidate shift for requested in "$@"; do candidate=$(normalize_submodule_path "$requested") if ! is_submodule_path "$root" "$candidate"; then error "unknown submodule path: $requested" return 1 fi done } submodule_path_selected() { local path="$1" local requested shift [ "$#" -gt 0 ] || return 0 for requested in "$@"; do if [ "$path" = "$(normalize_submodule_path "$requested")" ]; then return 0 fi done return 1 } normalize_add_path() { local root="$1" local requested="$2" local candidate="$requested" while [ "$candidate" != "/" ] && [ "${candidate%/}" != "$candidate" ]; do candidate=${candidate%/} done if is_submodule_path "$root" "$candidate"; then printf '%s\n' "$candidate" else printf '%s\n' "$requested" fi } restore_index() { local index_path="$1" local backup="$2" local had_index="$3" if [ "$had_index" = "true" ]; then cp "$backup" "$index_path" else rm -f "$index_path" fi } default_commit_message() { local root="$1" local add_directory="${2:-$root}" local path local key local submodule_path local is_submodule local has_non_submodule=false local joined="" local -a staged_paths=() local -a staged_submodules=() if [ "$#" -gt 2 ]; then shift 2 while IFS= read -r -d '' path; do staged_paths+=("$path") done < <(git -C "$add_directory" diff --cached --name-only -z -- "$@") else while IFS= read -r -d '' path; do staged_paths+=("$path") done < <(git -C "$root" diff --cached --name-only -z) fi [ "${#staged_paths[@]}" -gt 0 ] || return 1 for path in "${staged_paths[@]}"; do is_submodule=false while IFS= read -r -d '' key && IFS= read -r -d '' submodule_path; do if [ "$path" = "$submodule_path" ]; then is_submodule=true break fi done < <(submodule_entries "$root") if [ "$is_submodule" != "true" ]; then has_non_submodule=true fi done while IFS= read -r -d '' key && IFS= read -r -d '' submodule_path; do for path in "${staged_paths[@]}"; do if [ "$path" = "$submodule_path" ]; then staged_submodules+=("$path") break fi done done < <(submodule_entries "$root") if [ "$has_non_submodule" = "true" ]; then printf 'feat:\n' return 0 fi for path in "${staged_submodules[@]}"; do if [ -n "$joined" ]; then joined="$joined $path" else joined="$path" fi done printf 'update submodule: %s\n' "$joined" } admit_paths() { local root="$1" local add_directory="$2" shift 2 local index_path local backup local had_index=false local before_index local after_index local default_message local editor local editor_wrapper="" local status local commit_scope="selected" local key local path local requested local -a paths=() local -a commit_args=() [ "$#" -gt 0 ] || { error "admit requires at least one path or --all" return 1 } if [ "$1" = "--all" ]; then [ "$#" -eq 1 ] || { error "admit --all accepts no additional paths" return 1 } while IFS= read -r -d '' key && IFS= read -r -d '' path; do paths+=("$path") done < <(submodule_entries "$root") [ "${#paths[@]}" -gt 0 ] || { error "no submodules found" return 1 } add_directory="$root" else for requested in "$@"; do [ "${requested#-}" = "$requested" ] || { error "admit supports paths or --all" return 1 } paths+=("$(normalize_add_path "$root" "$requested")") done fi index_path=$(git -C "$root" rev-parse --path-format=absolute --git-path index) || return 1 backup=$(mktemp "${TMPDIR:-/tmp}/gits-index.XXXXXX") || return 1 if [ -f "$index_path" ]; then cp "$index_path" "$backup" || { rm -f "$backup" return 1 } had_index=true fi before_index=$(git -C "$root" ls-files --stage) || { rm -f "$backup" return 1 } trap 'restore_index "$index_path" "$backup" "$had_index"; rm -f "$backup" "$editor_wrapper"; error "admit cancelled; index restored"; trap - INT TERM HUP; exit 130' INT TERM HUP print_cmd "git add -- ${paths[*]}" git -C "$add_directory" add -- "${paths[@]}" || { status=$? restore_index "$index_path" "$backup" "$had_index" rm -f "$backup" trap - INT TERM HUP return "$status" } after_index=$(git -C "$root" ls-files --stage) || { restore_index "$index_path" "$backup" "$had_index" rm -f "$backup" trap - INT TERM HUP return 1 } git -C "$root" write-tree >/dev/null || { restore_index "$index_path" "$backup" "$had_index" rm -f "$backup" trap - INT TERM HUP return 1 } if [ "$before_index" = "$after_index" ]; then rm -f "$backup" trap - INT TERM HUP error "no changes added" return 1 fi if [ -f "$(git -C "$root" rev-parse --git-path MERGE_HEAD)" ]; then default_message=$(default_commit_message "$root") commit_scope="index" else default_message=$(default_commit_message "$root" "$add_directory" "${paths[@]}") fi [ -n "$default_message" ] || { restore_index "$index_path" "$backup" "$had_index" rm -f "$backup" trap - INT TERM HUP error "no staged changes to commit" return 1 } echo "default commit message: $default_message" editor=$(git -C "$root" var GIT_EDITOR) || { restore_index "$index_path" "$backup" "$had_index" rm -f "$backup" trap - INT TERM HUP error "cannot determine Git editor; index restored" return 1 } editor_wrapper=$(mktemp "${TMPDIR:-/tmp}/gits-editor.XXXXXX") || { restore_index "$index_path" "$backup" "$had_index" rm -f "$backup" trap - INT TERM HUP return 1 } printf '%s\n' \ '#!/bin/bash' \ 'snapshot=$(mktemp "${TMPDIR:-/tmp}/gits-message-before.XXXXXX") || exit 1' \ 'trap '\''rm -f "$snapshot"'\'' EXIT' \ 'marker=$(mktemp "${TMPDIR:-/tmp}/gits-message.XXXXXX") || exit 1' \ 'trap '\''rm -f "$snapshot" "$marker"'\'' EXIT' \ 'cp "$1" "$snapshot" || exit 1' \ 'touch -t 200001010000 "$marker" "$1" || exit 1' \ '/bin/sh -c "$GITS_ADMIT_EDITOR \"\$@\"" gits-admit-editor "$1"' \ 'editor_status=$?' \ 'cmp -s "$1" "$snapshot"' \ 'compare_status=$?' \ '[ "$compare_status" -eq 1 ] && exit 0' \ '[ "$compare_status" -eq 0 ] || exit "$compare_status"' \ '[ "$1" -nt "$marker" ] && exit 0' \ 'printf '\''gits: commit message was not saved\n'\'' >&2' \ '[ "$editor_status" -eq 0 ] || exit "$editor_status"' \ 'exit 1' > "$editor_wrapper" || { restore_index "$index_path" "$backup" "$had_index" rm -f "$backup" "$editor_wrapper" trap - INT TERM HUP return 1 } chmod +x "$editor_wrapper" || { restore_index "$index_path" "$backup" "$had_index" rm -f "$backup" "$editor_wrapper" trap - INT TERM HUP return 1 } if [ "$commit_scope" = "selected" ]; then commit_args=(commit --only --edit -m "$default_message" -- "${paths[@]}") else commit_args=(commit --edit -m "$default_message") fi GITS_ADMIT_EDITOR="$editor" GIT_EDITOR="$editor_wrapper" git -C "$add_directory" "${commit_args[@]}" || { status=$? restore_index "$index_path" "$backup" "$had_index" rm -f "$backup" "$editor_wrapper" trap - INT TERM HUP error "admit cancelled; index restored" return "$status" } rm -f "$backup" "$editor_wrapper" trap - INT TERM HUP } list_submodules() { local root="$1" local base="$2" local key local path local name local declared_url local resolved_url local mirror local state if [ -n "$base" ]; then printf 'shared modules repository: %s%s%s\n' "$GREEN" "$base" "$RESET" else printf 'shared modules repository: %sdisabled%s\n' "$RED" "$RESET" fi if [ ! -f "$root/.gitmodules" ]; then echo "no submodules" return 0 fi while IFS= read -r -d '' key && IFS= read -r -d '' path; do [ -n "$key" ] || continue name=${key#submodule.} name=${name%.path} declared_url=$(git config --file "$root/.gitmodules" --get "submodule.$name.url") printf '%s%s%s : %s' "$GREEN" "$path" "$RESET" "$declared_url" if [ -n "$base" ]; then resolved_url=$(git -C "$root" config --local --get "submodule.$name.url" 2>/dev/null) [ -n "$resolved_url" ] || resolved_url="$declared_url" mirror=$(mirror_path "$base" "$resolved_url") || return 1 state="not cached" [ -d "$mirror" ] && state="cached" printf ' -> %s (%s)\n' "$mirror" "$state" else printf '\n' fi done < <(submodule_entries "$root") } clean_state_file() { local base="$1" printf '%s/.gits/clean-config\n' "$base" } register_clean_scan_root() { local base="$1" local requested="$2" local root local state local existing root=$(canonical_existing_directory "$requested") || { error "cleanup scan root does not exist or is not readable: $requested" return 1 } state=$(clean_state_file "$base") while IFS= read -r existing; do if [ "$existing" = "$root" ]; then printf 'cleanup scan root already registered: %s\n' "$root" return 0 fi done < <(git config --file "$state" --get-all clean.scanRoot 2>/dev/null) git config --file "$state" --add clean.scanRoot "$root" || return 1 printf 'cleanup scan root registered: %s%s%s\n' "$GREEN" "$root" "$RESET" } remove_clean_scan_root() { local base="$1" local requested="$2" local root="$requested" local state local existing local found=false if [ -d "$requested" ]; then root=$(canonical_existing_directory "$requested") || return 1 elif [ "${requested#/}" = "$requested" ]; then root="$PWD/$requested" fi state=$(clean_state_file "$base") while IFS= read -r existing; do if [ "$existing" = "$root" ]; then found=true break fi done < <(git config --file "$state" --get-all clean.scanRoot 2>/dev/null) if [ "$found" != "true" ]; then error "cleanup scan root is not registered: $root" return 1 fi git config --file "$state" --fixed-value --unset-all clean.scanRoot "$root" || return 1 printf 'cleanup scan root removed: %s%s%s\n' "$YELLOW" "$root" "$RESET" printf 'no mirrors were deleted\n' } list_clean_scan_roots() { local base="$1" local state local root local found=false state=$(clean_state_file "$base") printf 'registered cleanup scan roots:\n' while IFS= read -r root; do [ -n "$root" ] || continue printf ' %s%s%s\n' "$GREEN" "$root" "$RESET" found=true done < <(git config --file "$state" --get-all clean.scanRoot 2>/dev/null) if [ "$found" != "true" ]; then printf ' (none)\n' fi } clean_candidate_key() { local mirror="$1" printf '%s' "$mirror" | git hash-object --stdin } format_size_kb() { local size_kb="$1" awk -v size="$size_kb" 'BEGIN { if (size >= 1048576) { printf "%.1f GiB", size / 1048576 } else if (size >= 1024) { printf "%.1f MiB", size / 1024 } else { printf "%d KiB", size } }' } scan_and_clean_mirrors() { local base="$1" local apply="$2" local state local last_scan_file="$base/.gits/last-scan" local repo_dir="$base/repositories" local temp local roots_file local alternates_file local mirrors_file local eligible_file local root local entry local mirror local mirror_name local objects local size_kb local key local alternates local object_db local alternate local resolved local mirror_key local mirror_path_value local mirror_objects local mirror_size local consumer_file local consumer_count local consumer local first_unused local now local age_seconds local age_days local remaining_days local human_size local blocked=false local used_count=0 local waiting_count=0 local eligible_count=0 local eligible_kb=0 local deleted_count=0 local deleted_kb=0 state=$(clean_state_file "$base") temp=$(mktemp -d "${TMPDIR:-/tmp}/gits-clean.XXXXXX") || return 1 roots_file="$temp/roots" alternates_file="$temp/alternates" mirrors_file="$temp/mirrors" eligible_file="$temp/eligible" mkdir "$temp/consumers" || { rm -rf "$temp" return 1 } : > "$alternates_file" : > "$mirrors_file" : > "$eligible_file" : > "$temp/last-scan" git config --file "$state" --get-all clean.scanRoot > "$roots_file" 2>/dev/null || true printf 'shared modules repository: %s%s%s\n' "$GREEN" "$base" "$RESET" if [ ! -s "$roots_file" ]; then rm -rf "$temp" error "no cleanup scan roots registered; use 'gits cleanup --append ' first" return 1 fi printf 'registered cleanup scan roots:\n' while IFS= read -r root; do printf ' %s%s%s\n' "$GREEN" "$root" "$RESET" if [ ! -d "$root" ] || [ ! -r "$root" ]; then error "cleanup scan root is unavailable: $root" blocked=true continue fi if ! find "$root" -type f -path '*/objects/info/alternates' -print0 >> "$alternates_file" 2> "$temp/find-errors"; then error "failed to scan cleanup root: $root" sed -n '1,5p' "$temp/find-errors" >&2 blocked=true fi done < "$roots_file" if [ -L "$repo_dir" ] || { [ -e "$repo_dir" ] && [ ! -d "$repo_dir" ]; }; then error "blocked invalid shared repositories directory: $repo_dir" blocked=true elif [ -d "$repo_dir" ]; then for entry in "$repo_dir"/.[!.]* "$repo_dir"/..?*; do [ -e "$entry" ] || continue mirror_name=$(basename "$entry") if [ "$mirror_name" = ".DS_Store" ] && [ ! -L "$entry" ] && [ -f "$entry" ]; then continue fi error "blocked unexpected entry in shared repositories: $entry" blocked=true done for entry in "$repo_dir"/*; do [ -e "$entry" ] || continue mirror_name=$(basename "$entry") if [ -L "$entry" ]; then error "blocked symbolic link in shared repositories: $entry" blocked=true continue fi case "$mirror_name" in *.git) ;; *) error "blocked unexpected entry in shared repositories: $entry" blocked=true continue ;; esac if [ ! -d "$entry" ] || [ "$(git -C "$entry" rev-parse --is-bare-repository 2>/dev/null)" != "true" ]; then error "blocked non-bare shared mirror: $entry" blocked=true continue fi mirror=$(canonical_existing_directory "$entry") || { error "cannot resolve shared mirror: $entry" blocked=true continue } if [ -L "$mirror/objects" ]; then error "blocked symbolic link for shared mirror objects: $mirror/objects" blocked=true continue fi objects=$(canonical_existing_directory "$mirror/objects") || { error "cannot resolve shared mirror objects: $mirror" blocked=true continue } size_kb=$(du -sk "$mirror" 2>/dev/null | awk '{print $1}') if [ -z "$size_kb" ]; then error "cannot measure shared mirror: $mirror" blocked=true continue fi key=$(clean_candidate_key "$mirror") || { blocked=true continue } : > "$temp/consumers/$key" printf '%s\t%s\t%s\t%s\n' "$key" "$mirror" "$objects" "$size_kb" >> "$mirrors_file" done fi while IFS= read -r -d '' alternates; do object_db=${alternates%/info/alternates} while IFS= read -r alternate || [ -n "$alternate" ]; do [ -n "$alternate" ] || continue case "$alternate" in /*) resolved=$(canonical_existing_directory "$alternate" 2>/dev/null) ;; *) resolved=$(canonical_existing_directory "$object_db/$alternate" 2>/dev/null) ;; esac if [ -z "$resolved" ]; then error "blocked invalid alternate in $alternates: $alternate" blocked=true continue fi while IFS=$'\t' read -r mirror_key mirror_path_value mirror_objects mirror_size; do if [ "$resolved" = "$mirror_objects" ]; then printf '%s\n' "$alternates" >> "$temp/consumers/$mirror_key" break fi done < "$mirrors_file" done < "$alternates" done < "$alternates_file" if [ "$blocked" = "true" ]; then printf 'blocked: cleanup scan is incomplete; no candidate state or mirrors were changed\n' >&2 rm -rf "$temp" return 1 fi now=$(date +%s) while IFS=$'\t' read -r mirror_key mirror_path_value mirror_objects mirror_size; do consumer_file="$temp/consumers/$mirror_key" consumer_count=$(wc -l < "$consumer_file" | tr -d ' ') human_size=$(format_size_kb "$mirror_size") if [ "$consumer_count" -gt 0 ]; then git config --file "$state" --remove-section "candidate.$mirror_key" 2>/dev/null || true printf '%s\tused\t%s\t%s\t%s\n' "$now" "$mirror_size" "$consumer_count" "$mirror_path_value" >> "$temp/last-scan" printf 'used: %s%s%s (%s, %s consumers)\n' "$GREEN" "$(basename "$mirror_path_value")" "$RESET" "$human_size" "$consumer_count" while IFS= read -r consumer; do printf ' %s\n' "$consumer" done < "$consumer_file" used_count=$((used_count + 1)) continue fi first_unused=$(git config --file "$state" --get "candidate.$mirror_key.firstUnused" 2>/dev/null) case "$first_unused" in ""|*[!0-9]*) first_unused="$now" git config --file "$state" "candidate.$mirror_key.firstUnused" "$first_unused" || { rm -rf "$temp" return 1 } git config --file "$state" "candidate.$mirror_key.mirror" "$mirror_path_value" || { rm -rf "$temp" return 1 } ;; esac age_seconds=$((now - first_unused)) [ "$age_seconds" -ge 0 ] || age_seconds=0 age_days=$((age_seconds / 86400)) if [ "$age_seconds" -ge "$CLEAN_GRACE_SECONDS" ]; then printf '%s\teligible\t%s\t0\t%s\n' "$now" "$mirror_size" "$mirror_path_value" >> "$temp/last-scan" printf 'eligible: %s%s%s (%s, unused %s days)\n' "$RED" "$(basename "$mirror_path_value")" "$RESET" "$human_size" "$age_days" printf '%s\t%s\t%s\n' "$mirror_key" "$mirror_path_value" "$mirror_size" >> "$eligible_file" eligible_count=$((eligible_count + 1)) eligible_kb=$((eligible_kb + mirror_size)) else remaining_days=$(((CLEAN_GRACE_SECONDS - age_seconds + 86399) / 86400)) printf '%s\twaiting\t%s\t0\t%s\n' "$now" "$mirror_size" "$mirror_path_value" >> "$temp/last-scan" printf 'waiting: %s%s%s (%s, unused %s days, %s days remaining)\n' "$YELLOW" "$(basename "$mirror_path_value")" "$RESET" "$human_size" "$age_days" "$remaining_days" waiting_count=$((waiting_count + 1)) fi done < "$mirrors_file" mv "$temp/last-scan" "$last_scan_file" || { rm -rf "$temp" return 1 } printf 'summary: %s used, %s waiting, %s eligible, %s reclaimable\n' "$used_count" "$waiting_count" "$eligible_count" "$(format_size_kb "$eligible_kb")" if [ "$apply" != "true" ]; then printf "dry run: use 'gits cleanup' or 'gits cleanup --apply' to delete eligible mirrors\n" rm -rf "$temp" return 0 fi while IFS=$'\t' read -r mirror_key mirror_path_value mirror_size; do if [ "$(dirname "$mirror_path_value")" != "$repo_dir" ] || [ -L "$mirror_path_value" ] || [ -L "$mirror_path_value/objects" ] || [ "$(git -C "$mirror_path_value" rev-parse --is-bare-repository 2>/dev/null)" != "true" ] || [ -s "$temp/consumers/$mirror_key" ]; then error "mirror failed deletion validation: $mirror_path_value" rm -rf "$temp" return 1 fi done < "$eligible_file" while IFS=$'\t' read -r mirror_key mirror_path_value mirror_size; do rm -rf "$mirror_path_value" || { error "failed to delete shared mirror: $mirror_path_value" rm -rf "$temp" return 1 } git config --file "$state" --remove-section "candidate.$mirror_key" 2>/dev/null || true printf 'deleted: %s%s%s\n' "$GREEN" "$mirror_path_value" "$RESET" deleted_count=$((deleted_count + 1)) deleted_kb=$((deleted_kb + mirror_size)) done < "$eligible_file" printf 'cleaned: %s mirrors, %s reclaimed\n' "$deleted_count" "$(format_size_kb "$deleted_kb")" rm -rf "$temp" } cleanup_repository() { local base="$1" shift local mode="apply" local status=0 local requested local -a scan_roots=() [ -d "$base" ] || { error "shared modules repository does not exist: $base" return 1 } case "$1" in "") ;; --apply) [ "$#" -eq 1 ] || { error "cleanup --apply accepts no additional arguments" return 1 } ;; --dry-run) [ "$#" -eq 1 ] || { error "cleanup --dry-run accepts no additional arguments" return 1 } mode="preview" ;; --list) [ "$#" -eq 1 ] || { error "cleanup --list accepts no additional arguments" return 1 } mode="list" ;; --remove) [ "$#" -eq 2 ] || { error "cleanup --remove requires exactly one root" return 1 } mode="remove" scan_roots+=("$2") ;; --append) while [ "$#" -gt 0 ]; do [ "$1" = "--append" ] && [ -n "$2" ] || { error "cleanup accepts repeated --append arguments" return 1 } scan_roots+=("$2") shift 2 done mode="append" ;; *) error "cleanup accepts --apply, --dry-run, --list, repeated --append , or --remove " return 1 ;; esac acquire_shared_lock "$base" || return 1 case "$mode" in append) for requested in "${scan_roots[@]}"; do register_clean_scan_root "$base" "$requested" || { status=1 break } done [ "$status" -ne 0 ] || list_clean_scan_roots "$base" || status=$? ;; list) list_clean_scan_roots "$base" || status=$? ;; remove) remove_clean_scan_root "$base" "${scan_roots[0]}" || status=$? [ "$status" -ne 0 ] || list_clean_scan_roots "$base" || status=$? ;; apply) scan_and_clean_mirrors "$base" true || status=$? ;; *) scan_and_clean_mirrors "$base" false || status=$? ;; esac release_shared_lock return "$status" } main() { local root local base local mode local working_directory while [ "${1:-}" = "-C" ]; do [ "$#" -ge 2 ] || { error "option -C requires a path" return 1 } working_directory="$2" if ! cd -- "$working_directory" 2>/dev/null; then error "cannot change to directory: $working_directory" return 1 fi shift 2 done case "$1" in help) shift run_help "$@" return $? ;; --help|-h) usage return 0 ;; version) if [ "${2:-}" = "--help" ] || [ "${2:-}" = "-h" ]; then [ "$#" -eq 2 ] || { error "version --help accepts no additional arguments" return 1 } command_help version return 0 fi [ "$#" -eq 1 ] || { error "version accepts no arguments" return 1 } print_version return 0 ;; --version|-v) [ "$#" -eq 1 ] || { error "$1 accepts no arguments" return 1 } print_version return 0 ;; __manpage) [ "$#" -eq 1 ] || { error "internal manpage generation accepts no arguments" return 1 } manpage return 0 ;; __completion) [ "$#" -eq 2 ] || { error "internal completion requires exactly one shell" return 1 } completion_script "$2" return $? ;; __complete-submodules) [ "$#" -eq 2 ] || return 0 case "$2" in root|cwd) ;; *) return 0 ;; esac command -v git >/dev/null 2>&1 || return 0 completion_submodule_paths "$2" return 0 ;; __complete-changed-paths) [ "$#" -eq 2 ] || return 0 case "$2" in root|cwd) ;; *) return 0 ;; esac command -v git >/dev/null 2>&1 || return 0 completion_changed_paths "$2" return 0 ;; esac if is_supported_command "$1" && { [ "${2:-}" = "--help" ] || [ "${2:-}" = "-h" ]; }; then [ "$#" -eq 2 ] || { error "$1 --help accepts no additional arguments" return 1 } command_help "$1" return $? fi case "$1" in ""|-q|--quiet|--cached|--list) ;; *) if ! is_supported_command "$1"; then print_unknown_command "$1" return 1 fi ;; esac require_git || return 1 root=$(git_root) || { error "not a Git repository" return 1 } case "$1" in unlock) [ "$#" -eq 1 ] || { [ "$#" -eq 3 ] && [ "$2" = "--terminate" ]; } || { error "usage: gits unlock [--terminate ]" return 1 } base=$(shared_base "$root") [ -n "$base" ] || { error "shared mode is not configured"; return 1; } unlock_shared "$base" "${3:-}" ;; init) if [ -n "$3" ]; then error "init accepts at most one shared_path" return 1 fi if [ -n "$2" ]; then enable_shared "$root" "$2" || return 1 fi base=$(shared_base "$root") if [ -n "$base" ]; then run_with_shared_lock "$base" shared_update "$root" "$base" else normal_init "$root" fi ;; pull) shift if [ "${1:-}" = "--all" ]; then [ "$#" -eq 1 ] || { error "pull --all accepts no additional paths" return 1 } shift fi base=$(shared_base "$root") if [ -n "$base" ]; then run_with_shared_lock "$base" shared_pull "$root" "$base" "$@" else normal_pull "$root" "$@" fi ;; reset) shift mode="" if [ "${1:-}" = "--hard" ]; then mode="$1" shift fi if [ "${1:-}" = "--all" ]; then [ "$#" -eq 1 ] || { error "reset --all accepts no additional paths" return 1 } shift fi reset_repository "$root" "$mode" "$@" ;; admit) shift admit_paths "$root" "$PWD" "$@" ;; config) [ -z "$3" ] || { error "config accepts at most one argument" return 1 } case "$2" in --unset) disable_shared "$root" ;; "") base=$(shared_base "$root") if [ -n "$base" ]; then echo "$base" else printf 'shared modules repository for this project: %sDisabled%s\n' "$RED" "$RESET" fi ;; *) enable_shared "$root" "$2" ;; esac ;; list|--list) [ -z "$2" ] || { error "list accepts no arguments" return 1 } base=$(shared_base "$root") list_submodules "$root" "$base" ;; cleanup) base=$(shared_base "$root") [ -n "$base" ] || { error "cleanup requires shared mode for the current project" return 1 } shift cleanup_repository "$base" "$@" ;; "") git -C "$root" submodule ;; add|status|update|deinit|foreach|summary|sync|set-branch|set-url|absorbgitdirs|-q|--quiet|--cached) git -C "$root" submodule "$@" ;; *) print_unknown_command "$1" return 1 ;; esac } main "$@"