-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathgit-sync.sh
executable file
·154 lines (128 loc) · 8.1 KB
/
git-sync.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
#!/bin/sh -eux
# Define trivial helper functions.
error() { echo "$@" >&2; }
yesno() (
[ -z "${__yn_prompt+x}" ] && [ -z "${__yn_response+x}" ] || exit 3
[ $# -eq 1 ] || exit 3
readonly __yn_prompt="$1"
while true; do
# We're using `printf` to elide the newline in a POSIX-compliant manner.
printf '%s' "${__yn_prompt}"
read -r __yn_response
[ "${__yn_response}" = 'y' ] && return 0
[ "${__yn_response}" = 'n' ] && return 1
done
)
# Define constants.
readonly sync_refs_namespace='refs/sync/'
# Divvy up the meat and potatoes of `git-sync` into the following functions.
parse_args() {
[ -z "${remote_name+x}" ] || exit 3
[ $# -eq 1 ] || { error "usage: git ${0##*/git-} <remote name>"; return 1; }
readonly remote_name="$1"
}
check_sanity() {
[ -n "${sync_refs_namespace+x}" ] && [ -n "${remote_name+x}" ] || exit 3
[ -z "${sync_refs_dir+x}" ] && [ -z "${__cs_exit_status+x}" ] || exit 3
[ $# -eq 0 ] || exit 3
# Implicit/unchecked preconditions: (i) file system is stable; and (ii) nothing else is messing with the repository for the duration of our execution.
#
# TODO: Determine the minimum version of Git required, and assert we have that on hand.
# Kill two birds with one stone:
#
# - ensure Git can locate the repository; and
# - resolve the path to our sync refs namespace (for later use).
#
# `git-rev-parse` will print a nice error for us if it can't locate the repository, so all we need to do is bail.
#
# We're stripping the trailing forward slash from `${sync_refs_namespace}` so the resultant path won't end with a trailing forward slash.
# The separate `readonly` and `|| return 1` construct exist to sate the peculiarities of `set -e`; see <http://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_22_16> and <http://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_25_16> respectively for details.
sync_refs_dir="$(git rev-parse --git-path "${sync_refs_namespace%%/}")" || return 1
readonly sync_refs_dir
(
# Accumulate errors (where practical) for the remaining sanity checks, so the user can fix 'em up in one go.
__cs_exit_status=0
# Ensure we have a valid named remote.
if git check-ref-format "refs/remotes/${remote_name}"; then
if git config --get "remote.${remote_name}.url" >/dev/null; then
# Ensure that remote is configured with a stock standard fetch refspec.
#
# TODO: Support common non-branch refspecs (like `+refs/tags/*:refs/tags/*` and `+refs/notes/*:refs/notes/*`), and arbitrary well-namespaced branch refspecs (like `+refs/heads/some/subset/*:refs/remotes/${remote_name}/*`).
[ "$(git config --get-all "remote.${remote_name}.fetch" | grep -Fcvx "+refs/heads/*:refs/remotes/${remote_name}/*")" -eq 0 ] || { error "fatal: '${remote_name}' has a non-default fetch refspec configured"; __cs_exit_status=1; }
else error "fatal: '${remote_name}' is not a named remote"; __cs_exit_status=1; fi
else error "fatal: '${remote_name}' is not a valid remote name"; __cs_exit_status=1; fi
# Ensure our working tree is clean.
[ "$(git status --porcelain | wc -l)" -eq 0 ] || { error 'fatal: working tree is dirty'; __cs_exit_status=1; }
# TODO: Ensure there's no rebase in progress.
# Ensure our sync refs namespace is empty, by checking for valid (packed or loose) refs, then invalid loose refs.
{ [ "$(git for-each-ref --count=1 "${sync_refs_namespace}" 2>/dev/null | wc -l)" -eq 0 ] && {
[ ! -e "${sync_refs_dir}" ] || {
[ -d "${sync_refs_dir}" ] && [ "$(find "${sync_refs_dir}" ! -type d | head -n 1 | wc -l)" -eq 0 ]
}
} } || { error "fatal: ${sync_refs_namespace} is not empty"; __cs_exit_status=1; }
return ${__cs_exit_status}
)
}
prime_clean_up() {
[ -n "${sync_refs_dir+x}" ] || exit 3
[ $# -eq 0 ] || exit 3
clean_up() {
# Obliterate our sync refs namespace.
# We _could_ do this with plumbing commands, which would free us from implementation details.
#
# ```
# git for-each-ref --format='delete %(refname)' "${sync_refs_namespace}" | git update-ref --stdin
# ```
#
# However, it's easier to just nuke the directory, since we know where it resides.
rm -rf "${sync_refs_dir}"
}
# Clean up after ourselves when we eventually terminate.
trap clean_up EXIT
}
preserve_local_refs() {
[ -n "${sync_refs_namespace+x}" ] || exit 3
[ $# -eq 0 ] || exit 3
# Enumerate all branches and tags configured for immunity from `git-sync`, massage them into refspecs that populate our local sync refs namespace, then do the deed with `git-fetch`.
#
# Note that per the docs, the keys that are matched against and printed are canonicalised such that section and variable names are lowercased.
git config --bool --name-only --get-regexp '^(branch|tag)\.(.+)\.protectfromsync$' 'true' | sed -e 's|\.protectfromsync$||' -e 's|^branch\.|heads/|' -e 's|^tag\.|tags/|' -e "s|^.*$|refs/&:${sync_refs_namespace}local/&|" | xargs git fetch --quiet .
}
fetch_from_remote() {
[ -n "${remote_name+x}" ] && [ -n "${sync_refs_namespace+x}" ] || exit 3
[ $# -eq 0 ] || exit 3
# We don't want `git-fetch` to update as per the default configuration variable `remote.${remote_name}.fetch` (hence `--refmap=''`) or our local tags (hence `--no-tags`).
# We expect our remote sync refs namespace to be empty, so we shouldn't have any refs to delete (hence no `--prune`) or force update (hence no plus `+` prefixes for our refspecs).
git fetch --quiet --refmap='' --no-tags "${remote_name}" "refs/heads/*:${sync_refs_namespace}remote/heads/*" "refs/tags/*:${sync_refs_namespace}remote/tags/*"
}
reconcile() {
[ -n "${sync_refs_namespace+x}" ] && [ -n "${remote_name+x}" ] || exit 3
[ -z "${__r_local_branch+x}" ] || exit 3
[ $# -eq 0 ] || exit 3
# Stage the reconciliation into our combined sync refs namespace.
(
for __r_local_branch in $(git for-each-ref --format='%(refname:strip=2)' 'refs/heads/'); do
if git show-ref --quiet --verify "${sync_refs_namespace}remote/heads/${__r_local_branch}"; then
echo "${sync_refs_namespace}remote/heads/${__r_local_branch}:${sync_refs_namespace}combined/heads/${__r_local_branch}"
fi
done
echo "${sync_refs_namespace}remote/tags/*:${sync_refs_namespace}combined/tags/*"
) | xargs git fetch --quiet .
git fetch --quiet . "+${sync_refs_namespace}local/heads/*:${sync_refs_namespace}combined/heads/*" "+${sync_refs_namespace}local/tags/*:${sync_refs_namespace}combined/tags/*"
# Summarise updates to local branches and tags, then prompt for whether we should proceed.
#
# TODO: Only prompt if destructive updates are implicated.
git fetch --dry-run --update-head-ok --prune . "+${sync_refs_namespace}combined/heads/*:refs/heads/*" "+${sync_refs_namespace}combined/tags/*:refs/tags/*"
yesno 'Continue [y,n]? ' || return 1
# Update remote-tracking branches, then update local branches and tags.
#
# Eagle-eyed readers will notice our local update fetch differs from the dry-run, in that it is passed the `--no-tags` option.
# Without it, we wind up re-creating tags that we just deleted.
#
# TODO: Instead of fetching with `--update-head-ok` then resetting the index and working tree, consider saving our current branch (if applicable, with `git symbolic-ref --short HEAD`), detaching HEAD (with `git checkout --detach`), performing our updates, then checking out our saved current branch (if applicable).
git fetch --update-head-ok --prune . "+${sync_refs_namespace}remote/heads/*:refs/remotes/${remote_name}/*"
git fetch --update-head-ok --prune --no-tags . "+${sync_refs_namespace}combined/heads/*:refs/heads/*" "+${sync_refs_namespace}combined/tags/*:refs/tags/*"
git reset --hard
}
# Chain those funky functions together.
parse_args "$@" && check_sanity && prime_clean_up && preserve_local_refs && fetch_from_remote && reconcile