-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcodespace-sync-commit
More file actions
261 lines (241 loc) · 12 KB
/
Copy pathcodespace-sync-commit
File metadata and controls
261 lines (241 loc) · 12 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
#!/usr/bin/env bash
# codespace-sync-commit: commit-level integration for `codespace sync`.
# Sourced by codespace-sync; assumes codespace-utils (+ codespace-remote) loaded.
#
# Model: EITHER side can produce commits. `cs_sync_integrate_local` brings
# local's branch tip to the single canonical history, keyed on the recorded
# synced_commit (the commit both sides last agreed on): when only one side moved
# since then it auto-picks that side (ours / theirs); when both moved it's
# ambiguous, so it prompts (default rebase, resolved in the local checkout) or,
# non-interactively, refuses. `--ours` / `--theirs` pre-seat the choice.
# `cs_sync_align_remote` then makes the remote's checked-out branch match the
# canonical tip (fast-forward, else a backed-up reset when local was rewritten).
#
# Transport: rather than the reverse-tunnel git-daemon sketched in the plan, we
# move history over plain ssh -- local fetches the remote worktree directly, and
# pushes the canonical tip to a NON-checked-out holding ref in the remote's base
# repo (shared object store), which the remote worktree then reconciles onto.
# We own the remote base repo, so a push is always authenticated and accepted
# (a non-checked-out ref is never refused), making this strictly simpler and
# more robust than a tunnel + transient daemon with no loss of capability.
if [ -n "${_CS_SYNC_COMMIT_LOADED:-}" ]; then
return 0 2>/dev/null || true
fi
_CS_SYNC_COMMIT_LOADED=1
# Holding ref name (under the base repo's shared ref store) for a branch's
# canonical tip in transit.
cs_sync_holding_ref() {
echo "refs/cs-sync/$1"
}
# cs_sync_remote_exists / cs_sync_remote_health now live in codespace-remote
# (co-located with cs_sync_remote_repair and the create cores) so the remote
# init primitive is self-contained; they remain available here via that source.
# "ours" outcome: local HEAD is the canonical history. Nothing to do to the
# local tree; we only tell the caller how the remote should converge. Echoes
# the align token `reset` when the remote can't fast-forward to local (a
# rewrite/divergence, so align must reset it -- backing the old tip up), else
# nothing (the remote fast-forwards). Pure (touches no refs). Args: repo_dir L R
cs_sync_take_ours() {
local repo_dir="$1" L="$2" R="$3"
if git -C "$repo_dir" merge-base --is-ancestor "$R" "$L" 2>/dev/null; then
>&2 echo "==> local is ahead; remote fast-forwards to local HEAD"
else
>&2 echo "==> local history rewritten/diverged since last sync; remote will be reset to local HEAD (old tip backed up)"
echo reset
fi
}
# "theirs" outcome: the remote tip is the canonical history. Fast-forward local
# onto it when possible, else back up the local tip to a hidden ref and
# hard-reset onto the remote. Emits no align token (align then ff/no-ops, since
# local already holds the canonical tip). Args: repo_dir branch L
cs_sync_take_theirs() {
local repo_dir="$1" branch="$2" L="$3"
if git -C "$repo_dir" merge-base --is-ancestor "$L" refs/cs-sync/remote 2>/dev/null; then
>&2 echo "==> remote advanced; fast-forwarding local to the remote tip"
git -C "$repo_dir" merge --ff-only refs/cs-sync/remote >&2 || return 1
else
>&2 echo "==> taking the remote tip; resetting local to it (old local tip backed up)"
git -C "$repo_dir" update-ref \
"refs/cs-sync/backup/local/$branch/$(date -u +%Y%m%dT%H%M%SZ)" "$L" 2>/dev/null || true
git -C "$repo_dir" reset --hard refs/cs-sync/remote >&2 || return 1
fi
}
# Rebase local's commits onto the remote tip (the "lose nothing from either
# side" integration). Leaves the rebase in progress and returns non-zero on
# conflict. Emits no align token (local ends a descendant of the remote tip,
# so align fast-forwards). Args: repo_dir
cs_sync_rebase_onto_remote() {
local repo_dir="$1"
>&2 echo "==> rebasing local commits onto the remote tip"
if ! git -C "$repo_dir" rebase refs/cs-sync/remote >&2; then
>&2 echo "err: rebase conflict integrating remote commits."
>&2 echo " resolve in $repo_dir (git rebase --continue), then re-run 'codespace sync'."
return 1
fi
}
# Granular resolve: rebase local's commits onto the remote tip, auto-resolving
# only the *conflicting* hunks toward the chosen side -- non-conflicting changes
# from BOTH sides are kept (no commits discarded). Note the rebase inversion:
# during `git rebase <base>` the replayed commits are git's "theirs" and the
# base (remote) is "ours", so the user's --ours (local wins) maps to -X theirs,
# and --theirs (remote wins) maps to -X ours. A conflict git can't auto-resolve
# (e.g. modify/delete) still stops the rebase + returns non-zero. Emits no align
# token (local ends a descendant of the remote tip). Args: repo_dir side(ours|theirs)
cs_sync_rebase_resolve() {
local repo_dir="$1" side="$2" xopt
case "$side" in
ours) xopt=theirs ;;
theirs) xopt=ours ;;
*) return 1 ;;
esac
>&2 echo "==> integrating: rebasing local onto the remote tip, '$side' wins conflicts"
if ! git -C "$repo_dir" rebase -X "$xopt" refs/cs-sync/remote >&2; then
>&2 echo "err: a conflict '$side' could not auto-resolve (e.g. modify/delete)."
>&2 echo " resolve in $repo_dir (git rebase --continue), then re-run 'codespace sync'."
return 1
fi
}
# Both sides advanced since the last sync: genuinely ambiguous. Interactive ->
# ask (rebase is the default, so neither side's progress is dropped). Non-
# interactive -> refuse (never silently rewrite history). Echoes the chosen
# resolution's align token; returns non-zero on abort / rebase conflict.
# Args: repo_dir branch L R interactive
cs_sync_integrate_ambiguous() {
local repo_dir="$1" branch="$2" L="$3" R="$4" interactive="$5"
if [ -z "$interactive" ]; then
>&2 echo "err: both sides advanced since the last sync; refusing to auto-resolve."
>&2 echo " re-run interactively, or pass --ours / --theirs (see 'codespace sync -h')."
return 1
fi
>&2 echo "==> both sides advanced since the last sync."
>&2 printf ' [r]ebase (default) [o]urs [t]heirs [a]bort > '
local ans=""; read -r ans || ans=r
[ -z "$ans" ] && ans=r
# o/t are granular (keep both sides, resolve only conflicts); for a wholesale
# "discard the other side" reset, re-run with --ours/--theirs --hard.
case "$ans" in
r|rebase) cs_sync_rebase_onto_remote "$repo_dir" ;;
o|ours) cs_sync_rebase_resolve "$repo_dir" ours ;;
t|theirs) cs_sync_rebase_resolve "$repo_dir" theirs ;;
*) >&2 echo " aborted."; return 1 ;;
esac
}
# Bring local's branch tip to the canonical history, keyed on the recorded
# synced_commit (S) -- the commit both sides last agreed on. From what each side
# did since S we pick, automatically when unambiguous:
# - only local moved -> ours (remote ff, or reset on a rewrite)
# - only remote moved -> theirs (ff local, or reset local on a remote rewrite)
# - both moved -> ambiguous: prompt (default rebase) / abort if not interactive
# `resolve` (ours|theirs) pre-seats the choice and skips detection. With no S
# (first sync / pruned) we fall back to a plain merge-base classification.
# All git chatter goes to stderr; stdout carries only the align token (`reset`
# when the caller must hard-reset the remote to local HEAD, else empty).
# Requires a clean working tree (caller stashes if dirty). Returns non-zero on
# rebase conflict or abort.
# `resolve` (ours|theirs) is granular by default (rebase keeping both sides,
# resolving only conflicts toward the chosen side); `hard` switches it to the
# wholesale reset (make one side win exactly, the other's divergent commits
# backed up + dropped).
# Args: host, dest_rel, branch, repo_dir, [resolve], [synced], [interactive], [hard]
cs_sync_integrate_local() {
local host="$1" dest_rel="$2" branch="$3" repo_dir="$4"
local resolve="${5:-}" synced="${6:-}" interactive="${7:-}" hard="${8:-}"
# Fetch the remote branch into a tracking ref. A fresh/empty remote (no such
# branch) just means there is nothing to integrate.
if ! GIT_SSH_COMMAND="ssh $(cs_ssh_opts)" \
git -C "$repo_dir" fetch --no-tags "$host:$dest_rel" \
"+refs/heads/$branch:refs/cs-sync/remote" 2>/dev/null; then
>&2 echo "note: no remote '$branch' to integrate yet (local is canonical)"
return 0
fi
local L R token="" rc=0
L="$(git -C "$repo_dir" rev-parse HEAD)"
R="$(git -C "$repo_dir" rev-parse refs/cs-sync/remote)"
# A baseline we can't actually see (pruned) is no baseline.
if [ -n "$synced" ] && ! git -C "$repo_dir" cat-file -e "$synced^{commit}" 2>/dev/null; then
synced=""
fi
if [ "$L" = "$R" ]; then
: # in sync
elif [ "$resolve" = ours ]; then
if [ -n "$hard" ]; then token="$(cs_sync_take_ours "$repo_dir" "$L" "$R")"
else cs_sync_rebase_resolve "$repo_dir" ours || rc=$?; fi
elif [ "$resolve" = theirs ]; then
if [ -n "$hard" ]; then cs_sync_take_theirs "$repo_dir" "$branch" "$L" || rc=$?
else cs_sync_rebase_resolve "$repo_dir" theirs || rc=$?; fi
elif [ -z "$synced" ]; then
# No fork point (first sync / pruned): classify from the merge-base.
local base; base="$(git -C "$repo_dir" merge-base "$L" "$R" 2>/dev/null)" || base=""
if [ "$R" = "$base" ]; then
: # only local advanced -> remote ff
elif [ "$L" = "$base" ]; then
>&2 echo "==> remote advanced; fast-forwarding local to its tip"
git -C "$repo_dir" merge --ff-only refs/cs-sync/remote >&2 || rc=$?
else
cs_sync_rebase_onto_remote "$repo_dir" || rc=$?
fi
else
# Real fork point: act on what each side committed since S.
local locald="" remoted=""
[ -n "$(git -C "$repo_dir" rev-list "$synced..$L" 2>/dev/null)" ] && locald=1
[ -n "$(git -C "$repo_dir" rev-list "$synced..$R" 2>/dev/null)" ] && remoted=1
if [ -n "$locald" ] && [ -n "$remoted" ]; then
token="$(cs_sync_integrate_ambiguous "$repo_dir" "$branch" "$L" "$R" "$interactive")" || rc=$?
elif [ -n "$locald" ]; then
token="$(cs_sync_take_ours "$repo_dir" "$L" "$R")"
elif [ -n "$remoted" ]; then
cs_sync_take_theirs "$repo_dir" "$branch" "$L" || rc=$?
elif git -C "$repo_dir" merge-base --is-ancestor "$L" "$R" 2>/dev/null; then
# neither moved past S but tips differ (stale marker / a racing
# background sync): the remote is ahead -> ff local onto it.
cs_sync_take_theirs "$repo_dir" "$branch" "$L" || rc=$?
else
# local is at/ahead of S, the remote is behind -> local wins.
token="$(cs_sync_take_ours "$repo_dir" "$L" "$R")"
fi
fi
git -C "$repo_dir" update-ref -d refs/cs-sync/remote 2>/dev/null || true
if [ "$rc" = 0 ] && [ -n "$token" ]; then echo "$token"; fi
return "$rc"
}
# Make the remote's checked-out branch match local's canonical tip. Pushes the
# tip to a holding ref in the remote base repo, then reconciles the remote
# worktree onto it: fast-forward if possible, else rebase (a no-op once local
# integrated the remote's commits), else reset --hard (canonical wins; the old
# tip is preserved in a `cs-sync/backup/<utc>` ref + reflog).
# Args: host, dest_rel, branch, repo_dir, base_rel, [reset]
cs_sync_align_remote() {
local host="$1" dest_rel="$2" branch="$3" repo_dir="$4" base_rel="$5" reset="${6:-}"
local holding
holding="$(cs_sync_holding_ref "$branch")"
>&2 echo "==> [$host] aligning remote to canonical tip"
if ! GIT_SSH_COMMAND="ssh $(cs_ssh_opts)" \
git -C "$repo_dir" push --force --no-verify \
"$host:$base_rel" "HEAD:$holding"; then
>&2 echo "err: [$host] could not push canonical tip to base repo ~/$base_rel"
return 1
fi
cs_ssh_script "$host" "$dest_rel" "$branch" "$reset" <<'REMOTE'
set -eu
dest_rel="$1"; branch="$2"; reset="$3"
cd "$HOME/$dest_rel"
target="refs/cs-sync/$branch"
backup="refs/cs-sync/backup/$branch/$(date -u +%Y%m%dT%H%M%SZ)"
reset_to_target() {
# preserve the old tip in a hidden ref (recoverable, doesn't clutter branches)
git update-ref "$backup" HEAD 2>/dev/null || true
git reset --hard "$target"
}
if [ -n "$reset" ]; then
reset_to_target
elif ! git merge --ff-only "$target" 2>/dev/null; then
# remote has its own commits; replay them (drop by patch-id if already in
# the canonical history). reset --hard only on genuine failure.
if ! git rebase "$target"; then
git rebase --abort 2>/dev/null || true
reset_to_target
fi
fi
git update-ref -d "$target" 2>/dev/null || true
REMOTE
}