neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Adopt existing repos on import, and reconcile-mode upstream sync
a366332 · Gabriel Morell · 2026-09-12 04:53
Message
{commit_body(@commit)}
Files changed
modified
config/config.exs
+3
−0
| 57 | 57 | # Walk a batch of stale remote actors every hour so cached public |
| 58 | 58 | # keys don't drift even when nobody's touching the actor. |
| 59 | 59 | {"@hourly", GitGud.Federation.Workers.RefreshRemoteActors}, |
| 60 | + # Re-sync repositories that opted into a scheduled upstream | |
| 61 | + # fetch. Anonymous — a private upstream is synced by hand. | |
| 62 | + {"@hourly", GitGud.Repositories.Workers.UpstreamSync}, | |
| 60 | 63 | # Prune `actions/cache` entries by age + per-repo size cap. |
| 61 | 64 | {"@hourly", GitGud.Workflows.Workers.CacheEviction}, |
| 62 | 65 | # Requeue workflow_jobs stuck in `running` after a claim-then- |
modified
lib/git_gud/imports.ex
+59
−7
| 368 | 368 | @doc """ |
| 369 | 369 | Create the local repository for `item` by mirror-cloning its remote. |
| 370 | 370 | |
| 371 | − Returns `{:ok, repository}`, `{:skip, reason}` when the name is already | |
| 372 | − taken locally (the operator can rename and retry rather than us | |
| 373 | − guessing a suffix), or `{:error, reason}`. | |
| 371 | + Returns `{:ok, repository}`, `{:link, repository}` when the name was | |
| 372 | + already taken by a repository that turns out to be the same project | |
| 373 | + (see below), `{:skip, reason}` when the name is taken by something | |
| 374 | + else, or `{:error, reason}`. | |
| 375 | + | |
| 376 | + ## Name collisions | |
| 377 | + | |
| 378 | + A repo of the same name already sitting in the destination usually | |
| 379 | + means the import is being re-run, or that someone pushed the project | |
| 380 | + here by hand before the forge migration got to it. Skipping that is | |
| 381 | + right when the two are unrelated and wrong when they're the same | |
| 382 | + project — so we ask git: if the local repo and the remote share a root | |
| 383 | + commit, the local one is adopted, pointed at this upstream for | |
| 384 | + syncing, and the item is reported as linked rather than imported. | |
| 385 | + | |
| 386 | + Adoption links only. The adopted repo has history of its own, so it | |
| 387 | + is set to `"reconcile"` sync mode and no ref is touched here — the | |
| 388 | + operator syncs from the repo's settings when they're ready. See | |
| 389 | + `GitGud.Repositories.link_upstream/3`. | |
| 374 | 390 | """ |
| 375 | 391 | def import_item(%ImportItem{} = item, %Import{} = import_row) do |
| 376 | 392 | destination = destination(import_row) |
| 394 | 410 | is_nil(destination) -> |
| 395 | 411 | {:error, :destination_missing} |
| 396 | 412 | |
| 397 | − name_taken?(destination, item.target_name) -> | |
| 398 | − {:skip, "a repository named #{item.target_name} already exists here"} | |
| 413 | + existing = existing_repository(destination, item.target_name) -> | |
| 414 | + adopt(existing, item, import_row) | |
| 399 | 415 | |
| 400 | 416 | true -> |
| 401 | 417 | create(destination, import_row, attrs) |
| 415 | 431 | def destination(%Import{organization_id: nil, owner_id: uid}), do: Repo.get(User, uid) |
| 416 | 432 | def destination(%Import{organization_id: oid}), do: Repo.get(Organization, oid) |
| 417 | 433 | |
| 418 | − defp name_taken?(destination, name) do | |
| 434 | + # `{:link, repo}` when the collision is the same project, `{:skip, _}` | |
| 435 | + # when it isn't — including when we couldn't tell, since a name | |
| 436 | + # collision has always been a permanent condition for the item rather | |
| 437 | + # than something to burn retries on. | |
| 438 | + defp adopt(existing, %ImportItem{} = item, %Import{} = import_row) do | |
| 439 | + case Repositories.link_upstream(existing, item.clone_url, credentials(import_row)) do | |
| 440 | + {:ok, repo} -> | |
| 441 | + {:link, repo} | |
| 442 | + | |
| 443 | + {:error, :unrelated_history} -> | |
| 444 | + {:skip, taken(item) <> " and shares no history with it"} | |
| 445 | + | |
| 446 | + {:error, :empty_repository} -> | |
| 447 | + {:skip, taken(item) <> " and has no commits to match against"} | |
| 448 | + | |
| 449 | + {:error, {:upstream_mismatch, url}} -> | |
| 450 | + {:skip, taken(item) <> " and already syncs from #{url}"} | |
| 451 | + | |
| 452 | + {:error, reason} -> | |
| 453 | + {:skip, taken(item) <> "; could not read the remote to compare: #{describe(reason)}"} | |
| 454 | + end | |
| 455 | + end | |
| 456 | + | |
| 457 | + defp taken(%ImportItem{target_name: name}), | |
| 458 | + do: "a repository named #{name} already exists here" | |
| 459 | + | |
| 460 | + defp describe({:git_fetch, out}) when is_binary(out) do | |
| 461 | + out | |
| 462 | + |> String.split("\n", trim: true) | |
| 463 | + |> Enum.take(-2) | |
| 464 | + |> Enum.join(" / ") | |
| 465 | + |> String.slice(0, 500) | |
| 466 | + end | |
| 467 | + | |
| 468 | + defp describe(other), do: other |> inspect() |> String.slice(0, 500) | |
| 469 | + | |
| 470 | + defp existing_repository(destination, name) do | |
| 419 | 471 | query = |
| 420 | 472 | case destination do |
| 421 | 473 | %Organization{id: oid} -> |
| 427 | 479 | ) |
| 428 | 480 | end |
| 429 | 481 | |
| 430 | − Repo.exists?(query) | |
| 482 | + Repo.one(query) | |
| 431 | 483 | end |
| 432 | 484 | |
| 433 | 485 | # `mirror` maps the remote's own public/private flag; `force` pins every |
modified
lib/git_gud/imports/import_item.ex
+7
−2
| 4 | 4 | discovery (`status: "pending"`) and then walked by |
| 5 | 5 | `GitGud.Imports.Workers.CloneRepo`, one Oban job per item, so a single |
| 6 | 6 | bad repo can't sink the batch. |
| 7 | + | |
| 8 | + `"linked"` is the status for a remote that already had a local | |
| 9 | + counterpart of the same name and lineage: nothing was cloned, the | |
| 10 | + existing repository was pointed at this upstream instead. See | |
| 11 | + `GitGud.Imports.import_item/2`. | |
| 7 | 12 | """ |
| 8 | 13 | |
| 9 | 14 | use Ecto.Schema |
| 12 | 17 | alias GitGud.Imports.Import |
| 13 | 18 | alias GitGud.Repositories.Repository |
| 14 | 19 | |
| 15 | − @statuses ~w(pending cloning imported skipped failed) | |
| 20 | + @statuses ~w(pending cloning imported linked skipped failed) | |
| 16 | 21 | |
| 17 | 22 | schema "repository_import_items" do |
| 18 | 23 | field :remote_name, :string |
| 36 | 41 | |
| 37 | 42 | def statuses, do: @statuses |
| 38 | 43 | |
| 39 | − def done?(%__MODULE__{status: s}), do: s in ~w(imported skipped failed) | |
| 44 | + def done?(%__MODULE__{status: s}), do: s in ~w(imported linked skipped failed) | |
| 40 | 45 | |
| 41 | 46 | def changeset(item, attrs) do |
| 42 | 47 | item |
modified
lib/git_gud/imports/workers/clone_repo.ex
+7
−1
| 1 | 1 | defmodule GitGud.Imports.Workers.CloneRepo do |
| 2 | 2 | @moduledoc """ |
| 3 | 3 | Imports one `ImportItem`: mirror-clone the remote and create the local |
| 4 | − repository row. | |
| 4 | + repository row — or, when a repository of that name and lineage is | |
| 5 | + already here, link that one to the remote for syncing (`linked`). | |
| 5 | 6 | |
| 6 | 7 | One job per item, so an unreachable or oversized remote fails on its |
| 7 | 8 | own rather than aborting the batch. Retries cover transient network |
| 43 | 44 | settle(import_row) |
| 44 | 45 | :ok |
| 45 | 46 | |
| 47 | + {:link, repository} -> | |
| 48 | + _ = Imports.mark_item(item, "linked", %{repository_id: repository.id, error: nil}) | |
| 49 | + settle(import_row) | |
| 50 | + :ok | |
| 51 | + | |
| 46 | 52 | {:skip, reason} -> |
| 47 | 53 | _ = Imports.mark_item(item, "skipped", %{error: reason}) |
| 48 | 54 | settle(import_row) |
modified
lib/git_gud/repositories.ex
+415
−8
| 1130 | 1130 | |
| 1131 | 1131 | @zero_sha String.duplicate("0", 40) |
| 1132 | 1132 | |
| 1133 | + @doc """ | |
| 1134 | + Point an existing repository at `url` as its upstream, but only when | |
| 1135 | + the two are the same project. | |
| 1136 | + | |
| 1137 | + "The same project" is the lineage test the rest of the forge uses: a | |
| 1138 | + shared root commit (see `list_related_repositories/2`). A name match | |
| 1139 | + alone isn't enough — two unrelated repos called `utils` must not end | |
| 1140 | + up syncing into each other. | |
| 1141 | + | |
| 1142 | + Establishing that means learning the remote's root commits, which | |
| 1143 | + takes a fetch; it lands in a scratch ref namespace that is deleted | |
| 1144 | + immediately afterwards. The objects stay in the repo either way — a | |
| 1145 | + win when the link is made (the first sync is then nearly free), and | |
| 1146 | + unreferenced garbage for the next `git gc` when it isn't. | |
| 1147 | + | |
| 1148 | + Linking does not sync. The repo has its own history, so the ref | |
| 1149 | + update is the operator's call: `upstream_sync_mode` is set to | |
| 1150 | + `"reconcile"` and nothing local is touched here. | |
| 1151 | + | |
| 1152 | + Returns `{:ok, repository}` (including when it was already linked to | |
| 1153 | + this same URL), or: | |
| 1154 | + | |
| 1155 | + * `{:error, {:upstream_mismatch, url}}` — already syncing elsewhere | |
| 1156 | + * `{:error, :unrelated_history}` — same name, different project | |
| 1157 | + * `{:error, :empty_repository}` — nothing local to compare against | |
| 1158 | + * `{:error, {:git_fetch, output}}` — the remote couldn't be read | |
| 1159 | + """ | |
| 1160 | + def link_upstream(repo, url, creds \\ nil) | |
| 1161 | + | |
| 1162 | + def link_upstream(%Repository{} = repo, url, creds) when is_binary(url) and url != "" do | |
| 1163 | + sanitized = sanitize_upstream_url(url) | |
| 1164 | + | |
| 1165 | + cond do | |
| 1166 | + repo.upstream_url == sanitized -> | |
| 1167 | + {:ok, repo} | |
| 1168 | + | |
| 1169 | + is_binary(repo.upstream_url) -> | |
| 1170 | + {:error, {:upstream_mismatch, repo.upstream_url}} | |
| 1171 | + | |
| 1172 | + true -> | |
| 1173 | + with {:ok, local} <- local_root_set(repo), | |
| 1174 | + {:ok, remote} <- remote_root_set(repo, url, creds) do | |
| 1175 | + if MapSet.disjoint?(local, remote) do | |
| 1176 | + {:error, :unrelated_history} | |
| 1177 | + else | |
| 1178 | + repo | |
| 1179 | + |> Ecto.Changeset.change( | |
| 1180 | + upstream_url: sanitized, | |
| 1181 | + upstream_sync_mode: "reconcile" | |
| 1182 | + ) | |
| 1183 | + |> Repo.update() | |
| 1184 | + end | |
| 1185 | + end | |
| 1186 | + end | |
| 1187 | + end | |
| 1188 | + | |
| 1189 | + defp local_root_set(%Repository{} = repo) do | |
| 1190 | + case root_shas(repo) do | |
| 1191 | + [] -> {:error, :empty_repository} | |
| 1192 | + shas -> {:ok, MapSet.new(shas)} | |
| 1193 | + end | |
| 1194 | + end | |
| 1195 | + | |
| 1196 | + # Fetch the remote's branches into a scratch namespace, read the root | |
| 1197 | + # commits reachable from them, then drop the refs again. | |
| 1198 | + defp remote_root_set(%Repository{disk_path: path}, url, creds) do | |
| 1199 | + namespace = "refs/link-check/#{System.unique_integer([:positive])}" | |
| 1200 | + | |
| 1201 | + fetch = | |
| 1202 | + System.cmd( | |
| 1203 | + "git", | |
| 1204 | + [ | |
| 1205 | + "-C", | |
| 1206 | + path, | |
| 1207 | + "fetch", | |
| 1208 | + "--no-tags", | |
| 1209 | + "--", | |
| 1210 | + url, | |
| 1211 | + "+refs/heads/*:#{namespace}/*" | |
| 1212 | + ], | |
| 1213 | + stderr_to_stdout: true, | |
| 1214 | + env: git_credential_env(creds) | |
| 1215 | + ) | |
| 1216 | + | |
| 1217 | + result = | |
| 1218 | + case fetch do | |
| 1219 | + {_out, 0} -> {:ok, MapSet.new(scratch_roots(path, namespace))} | |
| 1220 | + {out, _} -> {:error, {:git_fetch, redact_credentials(out, creds)}} | |
| 1221 | + end | |
| 1222 | + | |
| 1223 | + delete_refs(path, namespace) | |
| 1224 | + result | |
| 1225 | + end | |
| 1226 | + | |
| 1227 | + defp scratch_roots(path, namespace) do | |
| 1228 | + case System.cmd( | |
| 1229 | + "git", | |
| 1230 | + ["-C", path, "rev-list", "--max-parents=0", "--glob=#{namespace}/*"], | |
| 1231 | + stderr_to_stdout: true | |
| 1232 | + ) do | |
| 1233 | + {out, 0} -> | |
| 1234 | + out | |
| 1235 | + |> String.split("\n", trim: true) | |
| 1236 | + |> Enum.flat_map(fn hex -> | |
| 1237 | + case Git.from_hex(String.trim(hex)) do | |
| 1238 | + {:ok, sha} -> [sha] | |
| 1239 | + _ -> [] | |
| 1240 | + end | |
| 1241 | + end) | |
| 1242 | + | |
| 1243 | + _ -> | |
| 1244 | + [] | |
| 1245 | + end | |
| 1246 | + end | |
| 1247 | + | |
| 1248 | + defp delete_refs(path, namespace) do | |
| 1249 | + case System.cmd( | |
| 1250 | + "git", | |
| 1251 | + ["-C", path, "for-each-ref", "--format=%(refname)", namespace], | |
| 1252 | + stderr_to_stdout: true | |
| 1253 | + ) do | |
| 1254 | + {out, 0} -> | |
| 1255 | + out | |
| 1256 | + |> String.split("\n", trim: true) | |
| 1257 | + |> Enum.each(fn ref -> | |
| 1258 | + System.cmd("git", ["-C", path, "update-ref", "-d", String.trim(ref)], | |
| 1259 | + stderr_to_stdout: true | |
| 1260 | + ) | |
| 1261 | + end) | |
| 1262 | + | |
| 1263 | + _ -> | |
| 1264 | + :ok | |
| 1265 | + end | |
| 1266 | + | |
| 1267 | + :ok | |
| 1268 | + end | |
| 1269 | + | |
| 1133 | 1270 | @doc """ |
| 1134 | 1271 | Re-fetch an imported repository from the URL it was imported from. |
| 1135 | 1272 | |
| 1136 | − Mirror semantics: branches and tags are force-updated to match | |
| 1137 | − upstream and refs deleted upstream are pruned here, so anything pushed | |
| 1138 | − locally to a branch that also exists upstream is discarded. That is | |
| 1139 | − what "mirror of upstream" means; repos you intend to diverge shouldn't | |
| 1140 | − be re-synced. | |
| 1273 | + Two modes, taken from the repo's `upstream_sync_mode` unless `opts` | |
| 1274 | + carries an explicit `:mode`: | |
| 1275 | + | |
| 1276 | + * `"mirror"` (the default, and what an imported repo gets) — | |
| 1277 | + branches and tags are force-updated to match upstream and refs | |
| 1278 | + deleted upstream are pruned here, so anything pushed locally to a | |
| 1279 | + branch that also exists upstream is discarded. That is what | |
| 1280 | + "mirror of upstream" means. | |
| 1281 | + | |
| 1282 | + * `"reconcile"` (what an adopted repo gets, see `link_upstream/3`) — | |
| 1283 | + branches that can fast-forward do, branches upstream has and we | |
| 1284 | + don't are created, and nothing else is touched or pruned. A | |
| 1285 | + branch both sides have moved comes back in the result's `:plan` | |
| 1286 | + under `:diverged` for a human to resolve; see `upstream_status/2`. | |
| 1141 | 1287 | |
| 1142 | 1288 | The resulting ref changes are fed through `HookReceiver` exactly as a |
| 1143 | 1289 | push would be, so caches, `pushed_at`, webhooks and CI all see the new |
| 1146 | 1292 | `creds` is an optional `%{username: _, token: _}` for private |
| 1147 | 1293 | upstreams; it is used for this fetch only and never persisted. |
| 1148 | 1294 | """ |
| 1149 | − def sync_upstream(repo, creds \\ nil) | |
| 1295 | + def sync_upstream(repo, creds \\ nil, opts \\ []) | |
| 1150 | 1296 | |
| 1151 | − def sync_upstream(%Repository{upstream_url: nil}, _creds), do: {:error, :no_upstream} | |
| 1297 | + def sync_upstream(%Repository{upstream_url: nil}, _creds, _opts), do: {:error, :no_upstream} | |
| 1298 | + | |
| 1299 | + def sync_upstream(%Repository{upstream_url: url} = repo, creds, opts) when is_binary(url) do | |
| 1300 | + case Keyword.get(opts, :mode) || repo.upstream_sync_mode do | |
| 1301 | + "reconcile" -> reconcile_upstream(repo, url, creds) | |
| 1302 | + _ -> mirror_upstream(repo, url, creds) | |
| 1303 | + end | |
| 1304 | + end | |
| 1152 | 1305 | |
| 1153 | − def sync_upstream(%Repository{upstream_url: url} = repo, creds) when is_binary(url) do | |
| 1306 | + defp mirror_upstream(%Repository{} = repo, url, creds) do | |
| 1154 | 1307 | before_refs = read_refs(repo.disk_path) |
| 1155 | 1308 | |
| 1156 | 1309 | case fetch_upstream(repo.disk_path, url, creds) do |
| 1174 | 1327 | end |
| 1175 | 1328 | end |
| 1176 | 1329 | |
| 1330 | + @upstream_ns "refs/upstream-sync" | |
| 1331 | + | |
| 1332 | + @doc """ | |
| 1333 | + What a reconcile-mode sync would do, without doing any of it. | |
| 1334 | + | |
| 1335 | + Fetches upstream into the `refs/upstream-sync/*` tracking namespace — | |
| 1336 | + which touches no branch of ours — and classifies every branch: | |
| 1337 | + | |
| 1338 | + * `:created` — upstream has it, we don't | |
| 1339 | + * `:fast_forward` — ours is an ancestor of upstream's | |
| 1340 | + * `:up_to_date` — same commit | |
| 1341 | + * `:ahead` — upstream is an ancestor of ours (we have extra work) | |
| 1342 | + * `:diverged` — both sides have commits the other doesn't | |
| 1343 | + * `:local_only` — ours alone; upstream never had it, or deleted it | |
| 1344 | + | |
| 1345 | + Only the first two are safe to apply automatically, and they're what | |
| 1346 | + `sync_upstream/3` applies in reconcile mode. `:diverged` is the case | |
| 1347 | + that needs a human: a merge, a rebase, or a deliberate overwrite. | |
| 1348 | + """ | |
| 1349 | + def upstream_status(repo, creds \\ nil) | |
| 1350 | + | |
| 1351 | + def upstream_status(%Repository{upstream_url: nil}, _creds), do: {:error, :no_upstream} | |
| 1352 | + | |
| 1353 | + def upstream_status(%Repository{upstream_url: url} = repo, creds) do | |
| 1354 | + with :ok <- fetch_upstream_tracking(repo.disk_path, url, creds) do | |
| 1355 | + {:ok, plan_upstream(repo.disk_path)} | |
| 1356 | + end | |
| 1357 | + end | |
| 1358 | + | |
| 1359 | + # Reconcile: fast-forward what can fast-forward, leave everything else | |
| 1360 | + # exactly as it is and report it. Nothing local is overwritten and | |
| 1361 | + # nothing is pruned — that's what separates this from mirror mode. | |
| 1362 | + defp reconcile_upstream(%Repository{} = repo, url, creds) do | |
| 1363 | + with :ok <- fetch_upstream_tracking(repo.disk_path, url, creds) do | |
| 1364 | + plan = plan_upstream(repo.disk_path) | |
| 1365 | + | |
| 1366 | + changes = | |
| 1367 | + Enum.flat_map(plan.created ++ plan.fast_forward, fn entry -> | |
| 1368 | + ref = "refs/heads/" <> entry.branch | |
| 1369 | + old = entry.local_sha || @zero_sha | |
| 1370 | + | |
| 1371 | + case System.cmd( | |
| 1372 | + "git", | |
| 1373 | + ["-C", repo.disk_path, "update-ref", ref, entry.upstream_sha, old], | |
| 1374 | + stderr_to_stdout: true | |
| 1375 | + ) do | |
| 1376 | + {_, 0} -> [{old, entry.upstream_sha, ref}] | |
| 1377 | + _ -> [] | |
| 1378 | + end | |
| 1379 | + end) | |
| 1380 | + | |
| 1381 | + if changes != [] do | |
| 1382 | + _ = HookReceiver.receive(repo.id, render_hook_stdin(changes)) | |
| 1383 | + end | |
| 1384 | + | |
| 1385 | + {:ok, repo} = | |
| 1386 | + repo | |
| 1387 | + |> Ecto.Changeset.change(upstream_synced_at: DateTime.utc_now(:second)) | |
| 1388 | + |> Repo.update() | |
| 1389 | + | |
| 1390 | + {:ok, %{repository: repo, changes: changes, mode: "reconcile", plan: plan}} | |
| 1391 | + end | |
| 1392 | + end | |
| 1393 | + | |
| 1394 | + @doc """ | |
| 1395 | + Repositories that opted into scheduled upstream sync. | |
| 1396 | + | |
| 1397 | + Ordered oldest-sync-first so a backlog drains fairly rather than | |
| 1398 | + starving whatever sorts last. | |
| 1399 | + """ | |
| 1400 | + def list_auto_sync_repositories do | |
| 1401 | + from(r in Repository, | |
| 1402 | + where: r.upstream_auto_sync and not is_nil(r.upstream_url), | |
| 1403 | + order_by: [asc_nulls_first: r.upstream_synced_at, asc: r.id] | |
| 1404 | + ) | |
| 1405 | + |> Repo.all() | |
| 1406 | + end | |
| 1407 | + | |
| 1408 | + @doc "Record why an unattended sync didn't happen (or clear it)." | |
| 1409 | + def record_upstream_sync_error(%Repository{} = repo, message) do | |
| 1410 | + repo | |
| 1411 | + |> Ecto.Changeset.change(upstream_sync_error: String.slice(message, 0, 255)) | |
| 1412 | + |> Repo.update() | |
| 1413 | + end | |
| 1414 | + | |
| 1415 | + def clear_upstream_sync_error(%Repository{upstream_sync_error: nil} = repo), do: {:ok, repo} | |
| 1416 | + | |
| 1417 | + def clear_upstream_sync_error(%Repository{} = repo) do | |
| 1418 | + repo | |
| 1419 | + |> Ecto.Changeset.change(upstream_sync_error: nil) | |
| 1420 | + |> Repo.update() | |
| 1421 | + end | |
| 1422 | + | |
| 1423 | + @doc """ | |
| 1424 | + Resolve one diverged branch by taking upstream's side of it. | |
| 1425 | + | |
| 1426 | + The deliberate, one-branch version of what mirror mode does to | |
| 1427 | + everything: `branch` is force-updated to the tip in the | |
| 1428 | + `refs/upstream-sync/*` namespace that the last `upstream_status/2` or | |
| 1429 | + reconcile sync fetched. Local-only commits on that branch are left | |
| 1430 | + behind (they stay reachable through the reflog, nothing more). | |
| 1431 | + | |
| 1432 | + The other side — taking ours, or merging the two — needs no ref | |
| 1433 | + surgery here: ours is already what's checked in, and a merge is the | |
| 1434 | + in-browser resolve flow that doesn't exist yet. | |
| 1435 | + """ | |
| 1436 | + def overwrite_branch_from_upstream(%Repository{} = repo, branch) when is_binary(branch) do | |
| 1437 | + local = branch_map(repo.disk_path, "refs/heads/") | |
| 1438 | + upstream = branch_map(repo.disk_path, @upstream_ns <> "/") | |
| 1439 | + | |
| 1440 | + case Map.get(upstream, branch) do | |
| 1441 | + nil -> | |
| 1442 | + {:error, :no_upstream_branch} | |
| 1443 | + | |
| 1444 | + upstream_sha -> | |
| 1445 | + ref = "refs/heads/" <> branch | |
| 1446 | + old = Map.get(local, branch, @zero_sha) | |
| 1447 | + | |
| 1448 | + args = | |
| 1449 | + ["-C", repo.disk_path, "update-ref", ref, upstream_sha] ++ | |
| 1450 | + if(old == @zero_sha, do: [], else: [old]) | |
| 1451 | + | |
| 1452 | + case System.cmd("git", args, stderr_to_stdout: true) do | |
| 1453 | + {_, 0} -> | |
| 1454 | + _ = HookReceiver.receive(repo.id, render_hook_stdin([{old, upstream_sha, ref}])) | |
| 1455 | + {:ok, %{branch: branch, from: old, to: upstream_sha}} | |
| 1456 | + | |
| 1457 | + {out, _} -> | |
| 1458 | + {:error, {:git_update_ref, out}} | |
| 1459 | + end | |
| 1460 | + end | |
| 1461 | + end | |
| 1462 | + | |
| 1463 | + # Upstream's branches land under `refs/upstream-sync/*` and stay | |
| 1464 | + # there: they're what a later reconciliation merges against, and the | |
| 1465 | + # ref is what keeps those objects alive through a `git gc`. | |
| 1466 | + defp fetch_upstream_tracking(disk_path, url, creds) do | |
| 1467 | + args = [ | |
| 1468 | + "-C", | |
| 1469 | + disk_path, | |
| 1470 | + "fetch", | |
| 1471 | + "--prune", | |
| 1472 | + "--force", | |
| 1473 | + "--no-tags", | |
| 1474 | + "--", | |
| 1475 | + url, | |
| 1476 | + "+refs/heads/*:#{@upstream_ns}/*" | |
| 1477 | + ] | |
| 1478 | + | |
| 1479 | + case System.cmd("git", args, stderr_to_stdout: true, env: git_credential_env(creds)) do | |
| 1480 | + {_out, 0} -> :ok | |
| 1481 | + {out, _} -> {:error, {:git_fetch, redact_credentials(out, creds)}} | |
| 1482 | + end | |
| 1483 | + end | |
| 1484 | + | |
| 1485 | + defp plan_upstream(disk_path) do | |
| 1486 | + local = branch_map(disk_path, "refs/heads/") | |
| 1487 | + upstream = branch_map(disk_path, @upstream_ns <> "/") | |
| 1488 | + | |
| 1489 | + empty = %{ | |
| 1490 | + created: [], | |
| 1491 | + fast_forward: [], | |
| 1492 | + up_to_date: [], | |
| 1493 | + ahead: [], | |
| 1494 | + diverged: [], | |
| 1495 | + local_only: [] | |
| 1496 | + } | |
| 1497 | + | |
| 1498 | + plan = | |
| 1499 | + Enum.reduce(upstream, empty, fn {branch, upstream_sha}, acc -> | |
| 1500 | + local_sha = Map.get(local, branch) | |
| 1501 | + entry = classify_branch(disk_path, branch, local_sha, upstream_sha) | |
| 1502 | + Map.update!(acc, entry.status, &[Map.delete(entry, :status) | &1]) | |
| 1503 | + end) | |
| 1504 | + | |
| 1505 | + local_only = | |
| 1506 | + for {branch, sha} <- local, | |
| 1507 | + not Map.has_key?(upstream, branch), | |
| 1508 | + do: %{branch: branch, local_sha: sha, upstream_sha: nil} | |
| 1509 | + | |
| 1510 | + %{plan | local_only: local_only} | |
| 1511 | + |> Map.new(fn {k, v} -> {k, Enum.sort_by(v, & &1.branch)} end) | |
| 1512 | + end | |
| 1513 | + | |
| 1514 | + defp classify_branch(_path, branch, nil, upstream_sha), | |
| 1515 | + do: %{status: :created, branch: branch, local_sha: nil, upstream_sha: upstream_sha} | |
| 1516 | + | |
| 1517 | + defp classify_branch(_path, branch, sha, sha), | |
| 1518 | + do: %{status: :up_to_date, branch: branch, local_sha: sha, upstream_sha: sha} | |
| 1519 | + | |
| 1520 | + defp classify_branch(path, branch, local_sha, upstream_sha) do | |
| 1521 | + behind = ancestor?(path, local_sha, upstream_sha) | |
| 1522 | + ahead = ancestor?(path, upstream_sha, local_sha) | |
| 1523 | + | |
| 1524 | + base = %{ | |
| 1525 | + branch: branch, | |
| 1526 | + local_sha: local_sha, | |
| 1527 | + upstream_sha: upstream_sha, | |
| 1528 | + behind: count_between(path, local_sha, upstream_sha), | |
| 1529 | + ahead: count_between(path, upstream_sha, local_sha) | |
| 1530 | + } | |
| 1531 | + | |
| 1532 | + cond do | |
| 1533 | + behind -> Map.put(base, :status, :fast_forward) | |
| 1534 | + ahead -> Map.put(base, :status, :ahead) | |
| 1535 | + true -> Map.put(base, :status, :diverged) | |
| 1536 | + end | |
| 1537 | + end | |
| 1538 | + | |
| 1539 | + # Is `maybe_ancestor` reachable from `descendant`? | |
| 1540 | + defp ancestor?(path, maybe_ancestor, descendant) do | |
| 1541 | + case System.cmd( | |
| 1542 | + "git", | |
| 1543 | + ["-C", path, "merge-base", "--is-ancestor", maybe_ancestor, descendant], | |
| 1544 | + stderr_to_stdout: true | |
| 1545 | + ) do | |
| 1546 | + {_, 0} -> true | |
| 1547 | + _ -> false | |
| 1548 | + end | |
| 1549 | + end | |
| 1550 | + | |
| 1551 | + defp count_between(path, exclude_hex, include_hex) do | |
| 1552 | + case System.cmd( | |
| 1553 | + "git", | |
| 1554 | + ["-C", path, "rev-list", "--count", include_hex, "^" <> exclude_hex], | |
| 1555 | + stderr_to_stdout: true | |
| 1556 | + ) do | |
| 1557 | + {out, 0} -> out |> String.trim() |> String.to_integer() | |
| 1558 | + _ -> 0 | |
| 1559 | + end | |
| 1560 | + end | |
| 1561 | + | |
| 1562 | + # `%{"main" => "<sha hex>"}` for every ref under `prefix`. | |
| 1563 | + defp branch_map(disk_path, prefix) do | |
| 1564 | + case System.cmd( | |
| 1565 | + "git", | |
| 1566 | + ["-C", disk_path, "for-each-ref", "--format=%(objectname) %(refname)", prefix], | |
| 1567 | + stderr_to_stdout: true | |
| 1568 | + ) do | |
| 1569 | + {out, 0} -> | |
| 1570 | + out | |
| 1571 | + |> String.split("\n", trim: true) | |
| 1572 | + |> Enum.reduce(%{}, fn line, acc -> | |
| 1573 | + case String.split(line, " ", parts: 2) do | |
| 1574 | + [sha, ref] -> Map.put(acc, String.replace_prefix(ref, prefix, ""), sha) | |
| 1575 | + _ -> acc | |
| 1576 | + end | |
| 1577 | + end) | |
| 1578 | + | |
| 1579 | + _ -> | |
| 1580 | + %{} | |
| 1581 | + end | |
| 1582 | + end | |
| 1583 | + | |
| 1177 | 1584 | defp fetch_upstream(disk_path, url, creds) do |
| 1178 | 1585 | args = [ |
| 1179 | 1586 | "-C", |
modified
lib/git_gud/repositories/repository.ex
+15
−1
Click to load diff…
added
lib/git_gud/repositories/workers/upstream_sync.ex
+112
−0
Click to load diff…
modified
lib/git_gud_web/live/import_live/show.ex
+7
−1
Click to load diff…
modified
lib/git_gud_web/live/repo_live/settings.ex
+196
−11
Click to load diff…
added
priv/repo/migrations/20260912000000_add_upstream_sync_mode.exs
+17
−0
Click to load diff…
added
priv/repo/migrations/20260912010000_add_upstream_auto_sync.exs
+23
−0
Click to load diff…
added
test/git_gud/upstream_reconcile_test.exs
+424
−0
Click to load diff…
added
test/git_gud_web/live/repo_live/upstream_panel_test.exs
+149
−0
Click to load diff…
Parents: cafccc2