Adopt existing repos on import, and reconcile-mode upstream sync

a366332 · Gabriel Morell · 2026-09-12 04:53

32 files +1434 -31
Message
{commit_body(@commit)}

Files changed

modified config/config.exs
+3 −0
@@ -57,6 +57,9 @@ config :git_gud, Oban,
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,9 +368,25 @@ defmodule GitGud.Imports do
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,8 +410,8 @@ defmodule GitGud.Imports do
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,7 +431,43 @@ defmodule GitGud.Imports do
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,7 +479,7 @@ defmodule GitGud.Imports do
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,6 +4,11 @@ defmodule GitGud.Imports.ImportItem do
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,7 +17,7 @@ defmodule GitGud.Imports.ImportItem do
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,7 +41,7 @@ defmodule GitGud.Imports.ImportItem do
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,7 +1,8 @@
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,6 +44,11 @@ defmodule GitGud.Imports.Workers.CloneRepo do
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,14 +1130,160 @@ defmodule GitGud.Repositories do
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,11 +1292,18 @@ defmodule GitGud.Repositories do
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,6 +1327,260 @@ defmodule GitGud.Repositories 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