-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgit-import-file
More file actions
executable file
·277 lines (236 loc) · 9.59 KB
/
Copy pathgit-import-file
File metadata and controls
executable file
·277 lines (236 loc) · 9.59 KB
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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
#!/usr/bin/env bash
# git-import-file
#
#USAGE: git-import-file 'source-file' ['target-file']
# If the optional 'target-file' parameter is omitted, the file is copied
# to the same name as the source-file but in the current directory, ie.,
# it is copied to: ./"$(basename 'source-file')"
#
#REQUIRES:
# git -- hopefully, obviously.
# csplit -- used to split the commits that make up the files history into,
# temporary, files containing individual commits, so that we can
# process and apply them to produce the 'target-file'.
#
#DESCRIPTION:
# Imports a single file from a source repository into a target repository,
# including its complete commit history.
#
# Conditions:
# + target repository in clean state
# + The target file MUST NOT exist.
# + The commit history of the 'source-file' must be "fairly-standard":
# + Any rename must have been done atomically, that is, the commit
# performing the rename must not have modified the file contents.
# + ...
# Recommendations:
# + Make sure everything is backed up in case things go sideways...
# + Switch target repo into a fresh branch before running this
# +
#
# Author: Stuart Knock (2018-04-08)
#
# Disable the shellcheck info message about variables in printf format string.
# Prior to any other commands, like it is here, it applies to the whole file.
# shellcheck disable=SC2059
# Use the header as a basic help message.
[[ "$1" =~ ^('-h'|'--help')$ ]] && { head -n $((LINENO-6)) "${BASH_SOURCE[0]}"; exit 1; }
# Set some variables for message formatting.
declare -r ERROR='\e[1;31mERROR:\e[0m %s\n' # Bold Red error message format.
declare -r WARN='\e[1;33mWARNING:\e[0m %s\n' # Bold Yellow-ish warning message format.
# Capture the directory we start in, we'll do some cd below.
ORIGINAL_DIR="$(pwd -P)" || exit 1
declare -r ORIGINAL_DIR
############################################################################
########################### Function Definitions ###########################
############################################################################
# Make sure the repo is in a clean state
good_repo_status(){
local repo_status
if ! repo_status="$(git status --untracked-files=no --porcelain)"; then
printf "$ERROR" "Could not determine status of the git repo for '$( pwd -P )'."
return 1
fi
if [[ -n "$repo_status" ]]; then
printf "$ERROR" "The git repo containing '$( pwd -P )' has uncommitted changes."
printf ' %s\n' 'Commit, stash or revert changes before trying again.'
return 1
fi
return 0
} #good_repo_status()
clean_exit(){
# Clean-up temporary files on successful completion
if [[ "$1" == 0 ]]; then
[[ -d "${WORKING_DIR}" ]] && rm -rf "${WORKING_DIR}"
fi
# Change back to the starting directory.
cd "$ORIGINAL_DIR" || exit 1
exit "$1"
} #clean_exit()
capture_args(){
# Check the source-file exists.
declare -gr SOURCE="$1"
if [[ ! -f "$SOURCE" ]]; then
printf "$ERROR" "The source-file does not exist: '$SOURCE'."
return 1
fi
SOURCE_DIR="$( cd "$(dirname "${SOURCE}")" || return 1 ; pwd -P )"
declare -gr SOURCE_DIR
SOURCE_BASENAME="$(basename "$SOURCE")"
declare -gr SOURCE_BASENAME
# Set the target-file.
if [[ -n "$2" ]]; then
declare -gr TARGET="$2"
else
# Default to source-file name in current directory.
declare -gr TARGET="$ORIGINAL_DIR/$SOURCE_BASENAME"
fi
# Check that the target-file DOES NOT already exist.
if [[ -f "$TARGET" ]]; then
printf "$ERROR" "The target-file must not exist: '$TARGET'."
return 1
fi
TARGET_DIR="$( cd "$(dirname "${TARGET}")" || return 1 ; pwd -P )"
declare -gr TARGET_DIR
TARGET_BASENAME="$(basename "$TARGET")"
declare -gr TARGET_BASENAME
return 0
} #capture_args()
# Confirm the patch set includes the original commit.
have_initial_commit(){
local initial_commit
#NOTE: two checks is just to give us more confidence that we've actually
# found a match (first commit has previous sha1 as all zeros and
# previous file-path as /dev/null).
initial_commit="$(comm -12 <(grep -E --files-with-matches '0{40}' "${WORKING_DIR}/"xx*) \
<(grep --files-with-match '/dev/null' "${WORKING_DIR}/"xx*))"
if [[ "$initial_commit" != "${ORDERED_PATCH_SET[0]}" ]]; then
printf "$ERROR" 'We could not track history back to the initial commit.'
return 1
fi
return 0
} #have_initial_commit()
# Exclude patches that are renames.
exclude_renames(){
local rename_patch_file_list="${WORKING_DIR}/rename_patches.list"
grep --files-with-matches '^rename from ' "${WORKING_DIR}/"xx* \
> "$rename_patch_file_list"
if [[ -s "$rename_patch_file_list" ]]; then
local rename_patches
rename_patches=($(<"$rename_patch_file_list"))
# Extract all past and the present name of the source-file.
grep --only-matching -e '^rename from .*$' -e '^rename to .*$' \
"${rename_patches[@]}" \
| sed -e 's/rename from //g' -e 's/rename to //g' \
> "${WORKING_DIR}/all_source_names.txt"
#Remove the patches containing renames
while read -r rename_file; do
rm -f "$rename_file"
done < "$rename_patch_file_list"
else
printf '%s\n' "$SOURCE_RELATIVE" > "${WORKING_DIR}/all_source_names.txt"
fi
return 0
} #exclude_renames()
get_file_patch_set(){
# Create a temporary directory to hold our working files.
local name_template="/tmp/git-import-file-${SOURCE_BASENAME%.*}"
if ! WORKING_DIR="$(mktemp --directory "${name_template}-XXXX")"; then
return 1
fi
# Generate a full set of patches for the source-file.
ALL_PATCHES="${WORKING_DIR}/all_patches.txt"
git log \
--follow \
--pretty=email \
--patch-with-stat \
--full-index \
--binary \
-- "$SOURCE" > "$ALL_PATCHES"
# Split all-patches file into multiple temporary files, each containing a
# single commit (identified based on sha1).
csplit --silent \
--elide-empty-files \
--prefix="${WORKING_DIR}/xx" \
--suffix-format='%04d' \
"$ALL_PATCHES" '/^From [0-9a-f]\{40\}/' '{*}'
# Exclude commits that are renames/moves -- do not implicitly modify TARGET.
exclude_renames
#NOTE: reverse order here rather than using '--reverse' flag to git log as
# the git log flag prevents '--follow' from working.
ORDERED_PATCH_SET=($(sort --reverse <<< "$(printf '%s\n' "${WORKING_DIR}/"xx*)"))
declare -gr ORDERED_PATCH_SET
if ! have_initial_commit; then return 1; fi
# Concatenate the patches in reverse order
PATCHES_TO_USE="${WORKING_DIR}/patches_to_use.txt"
cat "${ORDERED_PATCH_SET[@]}" > "$PATCHES_TO_USE"
# Substitute current and past source-file names with a place-holder.
xargs -i \
sed --in-place \
-e 's#a/{}#a/SOURCE_FILE_NAME_WAS_HERE#g' \
-e 's#b/{}#b/SOURCE_FILE_NAME_WAS_HERE#g' \
-e 's#^ {}# SOURCE_FILE_NAME_WAS_HERE#g' \
"$PATCHES_TO_USE" \
< "${WORKING_DIR}/all_source_names.txt"
return 0
} #get_file_patch_set()
verify_import(){
local copy_status
diff -q "$SOURCE" "$TARGET_BASENAME" &> /dev/null
copy_status="$?" #NOTE: No-diff = 0; diff = 1; error = 2
if (( copy_status == 1 )); then
printf "$WARN" 'The generated target-file differs from the source-file.'
return 1
elif (( copy_status == 2 )); then
printf "$ERROR" 'Something unexpected went wrong...'
return 1
fi
return 0
}
############################################################################
############################### Main Script ################################
############################################################################
# Check we were provided at least one argument.
(( $# >= 1 )) || { printf "$ERROR" "'${BASH_SOURCE[0]}' requires an argument."; exit 1; }
# Capture and do basic checks on arguments, setting default target if necessary.
if ! capture_args "$1" "$2"; then exit 1; fi
# Change into the source-file directory.
cd "$SOURCE_DIR" || exit 1
# Check that the source git repo is in a clean state.
if ! good_repo_status; then clean_exit 1; fi
# SOURCE_REPO_DIR="$(git rev-parse --show-toplevel)"
SOURCE_RELATIVE_DIR="$(git rev-parse --show-prefix)"
if [[ -n "${SOURCE_RELATIVE_DIR}" ]]; then
SOURCE_RELATIVE="${SOURCE_RELATIVE_DIR}${SOURCE_BASENAME}"
else
SOURCE_RELATIVE="${SOURCE_BASENAME}"
fi
# Get a complete patch-set necessary to re-create the file, with history.
if ! get_file_patch_set; then clean_exit 1; fi
# Change into the target directory.
cd "$TARGET_DIR" || clean_exit 1
# Check that the target git repo is in a clean state.
if ! good_repo_status; then clean_exit 1; fi
# TARGET_REPO_DIR="$(git rev-parse --show-toplevel)"
TARGET_RELATIVE_DIR="$(git rev-parse --show-prefix)"
if [[ -n "${TARGET_RELATIVE_DIR}" ]]; then
TARGET_RELATIVE="${TARGET_RELATIVE_DIR}${TARGET_BASENAME}"
else
TARGET_RELATIVE="${TARGET_BASENAME}"
fi
# Substitute place-holder with repo-relative target-file name.
sed --in-place -e "s#SOURCE_FILE_NAME_WAS_HERE#${TARGET_RELATIVE}#g" "$PATCHES_TO_USE"
# Create file in target repository by applying ordered-patch-set.
if git apply --check -- "$PATCHES_TO_USE"; then
#NOTE: using am in order to preserve commit messages.
if ! git am --quiet < "$PATCHES_TO_USE"; then
git am --abort
printf "$ERROR" "Failed to apply generated patch-set."
clean_exit 1
fi
if ! verify_import; then clean_exit 1; fi
else
printf "$ERROR" "Failed to generate a complete history to apply to the target-file."
clean_exit 1
fi
clean_exit 0