//! `zit import-git SOURCE DEST --into`: add the upstream commits a destination
//! does not yet hold to the ones it has.
//!
//! The fresh import in `git_import_cli.zig` builds a whole store in a staging
//! directory and publishes it with one rename. That cannot apply here without
//! copying the destination. New frames are staged through a base-plus-tail
//! binding. A durable immutable prepared bundle precedes live append, after
//! which only exact-prefix roll-forward is allowed. Explicit recovery needs
//! neither the Git source nor a persistent operating-system file identity.
//!
//! What the destination already holds comes from the map (`git_import_map`):
//! every imported commit's OID, check-in name, branch and first-parent depth,
//! and every ref's tip. The export excludes those tips, names an excluded
//! parent by OID, and the importer resolves the name through the seeded map
//! rather than the stream.
const std = @import("std");
const zit = @import("zit");
const cli = zit.cli;
const logstore = zit.logstore;
const storage = zit.storage;
const fast_export = zit.git_fast_export;
const git_import = zit.git_import;
const import_cli = @import("git_import_cli.zig");
const map_file = zit.git_import_map;
const LogTreeStore = zit.log_tree_store.LogTreeStore;
const PublicationHooks = zit.publication.PublicationHooks;
const transaction = zit.import_transaction;
pub fn run(
gpa: std.mem.Allocator,
io: std.Io,
request: import_cli.Request,
streams: cli.Streams,
dependencies: import_cli.Dependencies,
) cli.Exit {
var ignored_result: import_cli.Result = .{};
const result = request.result orelse &ignored_result;
if (request.provenance) {
result.refuse("incompatible_settings");
streams.err.writeAll("import-git: --provenance applies to a fresh import; an import that adds to a destination keeps no per-object record\n") catch {};
return .failure;
}
var destination = request.destination_parent.openDir(io, request.destination_name, .{}) catch {
streams.err.print("import-git: destination '{s}' does not exist; omit --into to create it\n", .{request.destination_name}) catch {};
return .failure;
};
defer destination.close(io);
if (cli.storeDirOf(io, destination) catch return .failure) |resolved| {
destination.close(io);
destination = resolved;
}
// Validation is authoritative only after exclusion. In particular, never
// load an old map while another importer publishes a new log generation.
var store_append = cli.openStoreAppend(io, destination) catch |err| {
if (err == error.ImportRecoveryRequired) {
result.pending("pending_import");
} else if (err == error.StoreBusy) {
result.* = .{ .outcome = .busy, .reason = "store_busy", .retry = .later };
} else if (err == error.StoreLockAmbiguous) {
result.refuse("lock_ownership_ambiguous");
} else result.refuse("destination_unavailable");
switch (err) {
error.ImportRecoveryRequired => streams.err.writeAll("import-git: an incremental import is pending; run 'zit import-git --recover DEST'\n") catch {},
error.StoreBusy => {}, // the lock already named its holder
error.StoreLockAmbiguous => streams.err.writeAll("import-git: the store lock's owner cannot be established; inspect it before retrying\n") catch {},
else => streams.err.print("import-git: cannot write destination ({s})\n", .{@errorName(err)}) catch {},
}
return .failure;
};
var store_live = true;
defer if (store_live) store_append.close(io);
var map = map_file.load(gpa, io, destination) catch |err| {
result.refuse(if (err == error.Absent) "map_missing" else "map_invalid");
switch (err) {
error.Absent => streams.err.print(
"import-git: '{s}' has no commit map, so nothing says which upstream commits it holds; it was not written by this verb, or by a zit older than the map. Import into a fresh destination instead\n",
.{request.destination_name},
) catch {},
else => streams.err.print("import-git: the commit map of '{s}' cannot be read ({s})\n", .{ request.destination_name, @errorName(err) }) catch {},
}
return .failure;
};
defer map.deinit();
if (map.contents.mirror_classifier_fired) {
result.refuse("unsupported_mirror_metadata");
streams.err.writeAll("import-git: the first import of this destination met a Fossil mirror's own metadata; adding to such a destination is not supported, re-import it instead\n") catch {};
return .failure;
}
if (settingsConflict(request, map.contents.settings)) |message| {
result.refuse("incompatible_settings");
streams.err.print("import-git: {s}\n", .{message}) catch {};
return .failure;
}
// Refusal precedence is part of the machine contract. Inspect the locked
// destination read-only before suggesting that more Git history can help.
const log_stat = store_append.file.stat(io) catch {
result.refuse("destination_unavailable");
return .failure;
};
var file_storage = storage.FileStorage.initReadOnly(io, store_append.file, log_stat.size);
var log = logstore.LogStore.openReadOnly(gpa, file_storage.storageFile()) catch |err| {
result.refuse("store_invalid");
streams.err.print("import-git: cannot validate destination: {s}\n", .{@errorName(err)}) catch {};
return .failure;
};
var log_live = true;
defer if (log_live) log.deinit(gpa);
if (log.artifactCount() != map.contents.artifact_count) {
result.refuse("map_stale");
streams.err.print("import-git: '{s}' has been written to since its last import; its commit map no longer describes it\n", .{request.destination_name}) catch {};
return .failure;
}
validateMap(gpa, &map, &log) catch |err| {
result.refuse("map_stale");
streams.err.print("import-git: destination map is stale or incompatible: {s}\n", .{@errorName(err)}) catch {};
return .failure;
};
if (request.migrate_default_branch != null) {
const expected_default = request.expect_default_branch orelse {
result.refuse("migration_expectation_missing");
return .failure;
};
const expected_head = request.expect_imported_head orelse {
result.refuse("migration_expectation_missing");
return .failure;
};
const old_name = std.fmt.allocPrint(gpa, "refs/heads/{s}", .{map.contents.settings.default_branch}) catch return .failure;
defer gpa.free(old_name);
const old_ref = map.lookupRef(old_name) orelse {
result.refuse("default_ref_missing");
return .failure;
};
const old_head = map.lookupOid(old_ref.oid) orelse {
result.refuse("map_invalid");
return .failure;
};
if (!std.mem.eql(u8, expected_default, map.contents.settings.default_branch) or !std.mem.eql(u8, expected_head, old_head.checkin)) {
result.refuse("migration_expectation_stale");
streams.err.writeAll("import-git: default migration expectations no longer match the last successful import\n") catch {};
return .failure;
}
}
const describe = dependencies.git.vtable.describe_source orelse {
streams.err.writeAll("import-git: cannot identify the source, so cannot tell whether it is the one this destination was imported from\n") catch {};
return .failure;
};
var identity = describe(dependencies.git.context, gpa, request.source) catch |err| {
if (dependencies.git.vtable.startup_diagnostics) |write_diagnostics| write_diagnostics(dependencies.git.context, streams.err);
streams.err.print("import-git: cannot identify the source: {s}\n", .{@errorName(err)}) catch {};
return .failure;
};
defer identity.deinit(gpa);
const expected_format = if (map.contents.object_format == .sha1) "sha1" else "sha256";
if (!std.mem.eql(u8, identity.object_format, expected_format)) {
result.refuse("object_format_mismatch");
streams.err.writeAll("import-git: source object format differs from the destination import\n") catch {};
return .failure;
}
if (request.migrate_default_branch) |new_default| {
var present = false;
for (identity.refs) |ref| {
if (std.mem.startsWith(u8, ref.name, "refs/heads/") and std.mem.eql(u8, ref.name["refs/heads/".len..], new_default)) present = true;
}
if (!present) {
result.refuse("migration_ref_missing");
streams.err.writeAll("import-git: the requested replacement default branch is not in the captured source\n") catch {};
return .failure;
}
}
if (unknownCut(identity, &map)) |cut| {
result.* = .{ .outcome = .insufficient_history, .reason = if (cut.none_known) "shallow_identity_unproven" else "unknown_shallow_boundary", .retry = .deepen };
if (cut.none_known) {
// Nothing in the source is a commit the destination holds, so
// nothing ties it to that history: another repository, or this
// one cut off above everything already imported. Git cannot tell
// the two apart from what such a source holds.
streams.err.print("import-git: '{s}' is not the repository '{s}' was imported from, or is a shallow clone of it cut off at commit {s}, above every commit it holds; fetch it deeper (git fetch --deepen) until the cut is at a commit already imported\n", .{ request.source, request.destination_name, cut.commit }) catch {};
} else {
streams.err.print("import-git: '{s}' is shallow and cut off at commit {s}, which '{s}' does not hold; fetch it deeper (git fetch --deepen) until the cut is at a commit already imported\n", .{ request.source, cut.commit, request.destination_name }) catch {};
}
return .failure;
}
if (!sameSource(identity, map.contents)) {
result.refuse("wrong_source");
streams.err.print("import-git: '{s}' is not the repository '{s}' was imported from: a root commit the destination holds is not in its history\n", .{ request.source, request.destination_name }) catch {};
return .failure;
}
if (request.migrate_default_branch) |new_default| {
const old_name = std.fmt.allocPrint(gpa, "refs/heads/{s}", .{map.contents.settings.default_branch}) catch return .failure;
defer gpa.free(old_name);
const old_ref = map.lookupRef(old_name).?; // validated before source inspection
var replacement: ?[]const u8 = null;
for (identity.refs) |ref| {
if (std.mem.startsWith(u8, ref.name, "refs/heads/") and std.mem.eql(u8, ref.name["refs/heads/".len..], new_default)) replacement = ref.oid;
}
const target = replacement.?; // captured replacement was checked above
const related: ?bool = if (std.mem.eql(u8, old_ref.oid, target)) true else blk: {
const prove = dependencies.git.vtable.first_parent_contains orelse {
result.refuse("migration_proof_unavailable");
return .failure;
};
break :blk prove(dependencies.git.context, request.source, old_ref.oid, target) catch {
result.refuse("migration_proof_failed");
return .failure;
};
};
if (related == null) {
result.* = .{ .outcome = .insufficient_history, .reason = "migration_ancestry_incomplete", .retry = .deepen };
streams.err.writeAll("import-git: shallow history cannot prove default migration continuity; fetch deeper\n") catch {};
return .failure;
}
if (!related.?) {
result.refuse("migration_not_first_parent_continuation");
streams.err.writeAll("import-git: replacement default is not the old tip or its first-parent continuation\n") catch {};
return .failure;
}
}
var stage = transaction.begin(io, destination) catch |err| {
streams.err.print("import-git: cannot stage import: {s}\n", .{@errorName(err)}) catch {};
return .failure;
};
defer stage.close(io);
defer destination.deleteTree(io, transaction.staging_name) catch {};
const tail = stage.createFile(io, "tail", .{ .read = true, .exclusive = true }) catch return .failure;
defer tail.close(io);
var tail_storage = storage.FileStorage.init(io, tail, 0);
var overlay: transaction.Overlay = .{ .base = file_storage.storageFile(), .tail = tail_storage.storageFile(), .base_len = log_stat.size };
// Reuse the fully validated scan, but authorize writes only to the private
// appended tail. No repairing scanner ever touches the live destination.
log.file = overlay.file();
log.writable = true;
var outcome = importMore(gpa, io, request, &map, &identity, &log, stage, streams, dependencies) catch |err| {
result.refuse(if (err == error.InvalidPrimaryBranch) "default_ref_missing" else "import_failed");
streams.err.print("import-git: import failed: {s}; the destination is unchanged\n", .{@errorName(err)}) catch {};
return .failure;
};
defer outcome.deinit(gpa);
const default_changed = if (request.migrate_default_branch) |name| !std.mem.eql(u8, name, map.contents.settings.default_branch) else false;
if (outcome.added_commits == 0 and sameRefs(&identity, &map) and !default_changed) {
result.success(false, 0);
if (!request.quiet) streams.err.print("import-git: added 0 check-in(s) to '{s}'\n", .{request.destination_name}) catch {};
return .ok;
}
transaction.prepare(io, destination, stage, store_append.file, dependencies.publication) catch |err| {
result.refuse("prepare_failed");
transaction.requireIdle(io, destination) catch result.pending("prepare_uncertain");
streams.err.print("import-git: preparation interrupted ({s}); if pending, run 'zit import-git --recover DEST'\n", .{@errorName(err)}) catch {};
return .failure;
};
_ = transaction.recover(io, destination, dependencies.publication) catch |err| {
result.pending("publication_interrupted");
if (map_file.load(gpa, io, destination)) |published_map| {
var published = published_map;
defer published.deinit();
if (published.contents.publication_token) |token| {
if (std.mem.eql(u8, &token, &outcome.publication_token)) {
result.* = .{ .outcome = .committed_uncertain, .reason = "durability_uncertain", .changed = true, .committed = true, .durability = .uncertain, .retry = .recover, .added_checkins = outcome.added_commits };
}
}
} else |_| {}
streams.err.print("import-git: recovery_required ({s}); run 'zit import-git --recover DEST'; no live bytes were rolled back\n", .{@errorName(err)}) catch {};
return .failure;
};
log.deinit(gpa);
log_live = false;
store_append.close(io);
store_live = false;
// Pre-pay the derived-state cache against the published store, as the
// fresh import does; best-effort like every cache write.
zit.Repository.warmCache(gpa, io, destination);
result.success(true, outcome.added_commits);
if (!request.quiet) {
streams.err.print("import-git: added {d} check-in(s) to '{s}'\n", .{ outcome.added_commits, request.destination_name }) catch {};
for (outcome.notes.items) |note| streams.err.print("import-git: {s}\n", .{note}) catch {};
}
return .ok;
}
fn sameRefs(identity: *const import_cli.SourceIdentity, map: *const map_file.Map) bool {
if (identity.refs.len != map.contents.refs.len) return false;
for (identity.refs) |ref| {
const held = map.lookupRef(ref.name) orelse return false;
if (!std.mem.eql(u8, held.oid, ref.oid)) return false;
}
return true;
}
fn validateMap(gpa: std.mem.Allocator, map: *const map_file.Map, log: *logstore.LogStore) !void {
if (map.contents.publication_token != null) {
const hash = try map_file.inventoryHash(gpa, log);
if (!std.mem.eql(u8, &hash, &map.contents.inventory_hash)) return error.ImportBaseMismatch;
}
for (map.contents.commits) |commit| {
var bytes: [32]u8 = undefined;
const hash = std.fmt.hexToBytes(&bytes, commit.checkin) catch return error.InvalidMappedHash;
if (log.isShunned(hash)) return error.MappedArtifactShunned;
const rid = log.lookup(hash) orelse return error.MappedArtifactMissing;
const content = (try log.content(gpa, rid)) orelse return error.MappedArtifactMissing;
defer gpa.free(content);
if (!zit.artifact.verify(commit.checkin, content)) return error.MappedArtifactCorrupt;
var manifest = try zit.artifact.parseForImport(gpa, content);
defer manifest.deinit(gpa);
if (manifest.manifest.kind != .manifest) return error.MappedArtifactNotCheckin;
}
for (map.contents.refs) |ref| {
if (map.lookupOid(ref.oid) == null) return error.InvalidMappedRef;
}
var config = (try log.readConfig(gpa)) orelse return error.ConfigMissing;
defer config.deinit(gpa);
if (config.config.default_branch.len == 0) return error.AmbiguousPrimaryBranch;
if (map.contents.publication_token != null and !std.mem.eql(u8, config.config.default_branch, map.contents.primary_label)) return error.IncompatiblePrimaryBranch;
for (map.contents.commits) |commit| {
if (std.mem.eql(u8, commit.branch, config.config.default_branch)) return;
}
return error.IncompatiblePrimaryBranch;
}
pub fn recover(io: std.Io, dir: std.Io.Dir, streams: cli.Streams, hooks: PublicationHooks) cli.Exit {
var result: import_cli.Result = .{};
return recoverResult(io, dir, streams, hooks, &result);
}
pub fn recoverResult(io: std.Io, dir: std.Io.Dir, streams: cli.Streams, hooks: PublicationHooks, result: *import_cli.Result) cli.Exit {
// A mistyped path must never read as a durable no-op success.
const probe = cli.storeDirOf(io, dir) catch {
result.refuse("destination_unavailable");
return .failure;
};
const store_dir = probe orelse dir;
defer if (probe) |store| store.close(io);
_ = store_dir.statFile(io, ".zit", .{}) catch {
result.refuse("not_a_repository");
streams.err.writeAll("import-git: recovery destination holds no zit store\n") catch {};
return .failure;
};
var lock = cli.lockStoreRecovery(io, dir) catch |err| {
if (err == error.StoreBusy) result.* = .{ .outcome = .busy, .reason = "store_busy", .retry = .later } else result.refuse("destination_unavailable");
if (err == error.StoreLockAmbiguous) result.reason = "lock_ownership_ambiguous";
streams.err.print("import-git: recovery lock failed: {s}\n", .{@errorName(err)}) catch {};
return .failure;
};
defer lock.release(io);
const resolved = cli.storeDirOf(io, dir) catch return .failure;
defer if (resolved) |store| store.close(io);
const changed = transaction.recover(io, resolved orelse dir, hooks) catch |err| {
result.pending("recovery_interrupted");
switch (err) {
// Missing or malformed members arise only from outside interference;
// repeating recovery cannot repair them.
error.DamagedImportJournal, error.ForeignImportSuffix, error.ImportBaseMismatch, error.ImportMapMismatch, error.ConflictingImportState, error.FileNotFound, error.NotDir, error.IsDir => {
result.outcome = .refused;
result.reason = "recovery_conflict";
result.retry = .none;
},
else => {},
}
streams.err.print("import-git: recovery refused: {s}; preserve the store, map and pending bundle\n", .{@errorName(err)}) catch {};
return .failure;
};
result.success(changed, null);
result.outcome = if (changed) .recovered_changed else .recovered_unchanged;
streams.err.writeAll(if (changed) "import-git: recovered prepared import\n" else "import-git: no pending import\n") catch {};
return .ok;
}
/// A flag that contradicts what the first import recorded, as a message, or
/// null when the request is compatible. An absent flag means "reuse".
fn settingsConflict(request: import_cli.Request, recorded: map_file.Settings) ?[]const u8 {
switch (request.submanifest) {
.flat => if (recorded.submanifest != .flat) return "--no-submanifests contradicts the destination, which was imported with sub-manifests; omit it",
.tree => |pin| if (pin) |k| switch (recorded.submanifest) {
.flat => return "--submanifest-k contradicts the destination, which was imported flat; omit it",
.tree => |recorded_k| if (recorded_k != k) return "--submanifest-k contradicts the k the destination was imported with; omit it",
},
}
const requested_attribution: map_file.Attribution = switch (request.attribution) {
.committer => .committer,
.author => .author,
};
if (requested_attribution != recorded.attribution) return "--attribution contradicts the identities the destination was imported with; omit it";
if (request.refuse_signed != recorded.refuse_signed) {
return if (request.refuse_signed)
"--refuse-signed contradicts the destination, which was imported with signatures stripped; omit it"
else
"the destination was imported with --refuse-signed; pass it again";
}
if (request.default_branch) |requested| {
if (!std.mem.eql(u8, requested, recorded.default_branch)) return "--default-branch contradicts the main branch the destination was imported with; omit it";
}
return null;
}
/// The first commit a shallow source is cut off at that the destination does
/// not hold, or null. Beneath a cut the source has nothing, so every commit
/// the export names by OID there must already be in the destination; a cut
/// at a commit the map does not know would turn that commit into a root.
///
/// `none_known` is set when no commit of the cut is one the destination
/// holds, which leaves the source with no evidence it is this history at all.
fn unknownCut(identity: import_cli.SourceIdentity, map: *const map_file.Map) ?struct { commit: []const u8, none_known: bool } {
var unknown: ?[]const u8 = null;
var known: usize = 0;
for (identity.shallow) |commit| {
if (map.lookupOid(commit) == null) {
if (unknown == null) unknown = commit;
} else known += 1;
}
const commit = unknown orelse return null;
return .{ .commit = commit, .none_known = known == 0 };
}
/// The source is the one the destination came from when every root the
/// destination recorded is still a root of the source. The source may have
/// gained roots since (an orphan branch, a first notes commit); it cannot
/// have lost one and still be the history the destination holds.
///
/// A shallow source has lost its roots to the cut. For it the cut is the
/// evidence instead: `unknownCut` has proved every commit it is cut off at is
/// one the destination holds, and a commit's OID names its whole ancestry.
fn sameSource(identity: import_cli.SourceIdentity, recorded: map_file.Contents) bool {
const format: map_file.ObjectFormat = if (std.mem.eql(u8, identity.object_format, "sha256")) .sha256 else .sha1;
if (format != recorded.object_format) return false;
if (identity.shallow.len != 0) return true;
for (recorded.roots) |theirs| {
const present = for (identity.roots) |mine| {
if (std.mem.eql(u8, mine, theirs)) break true;
} else false;
if (!present) return false;
}
return true;
}
const Outcome = struct {
added_commits: usize,
publication_token: [32]u8 = @splat(0),
notes: std.ArrayList([]u8),
fn deinit(self: *Outcome, gpa: std.mem.Allocator) void {
for (self.notes.items) |note| gpa.free(note);
self.notes.deinit(gpa);
self.* = undefined;
}
};
/// Stage a complete new map and only the new log tail. Errors before PREPARED
/// discard staging, never mutate the live store.
fn importMore(
gpa: std.mem.Allocator,
io: std.Io,
request: import_cli.Request,
map: *const map_file.Map,
identity: *const import_cli.SourceIdentity,
log: *logstore.LogStore,
destination: std.Io.Dir,
streams: cli.Streams,
dependencies: import_cli.Dependencies,
) !Outcome {
const recorded = map.contents;
const hooks = dependencies.publication;
// The tips to exclude: every ref's peeled commit, each once.
var tips: std.ArrayList([]const u8) = .empty;
defer tips.deinit(gpa);
var tip_set: std.StringHashMapUnmanaged(void) = .empty;
defer tip_set.deinit(gpa);
for (recorded.refs) |ref| {
const seen = try tip_set.getOrPut(gpa, ref.oid);
if (seen.found_existing) continue;
try tips.append(gpa, ref.oid);
}
var pipes = try dependencies.git.vtable.start_export(dependencies.git.context, request.source, tips.items);
var session_finished = false;
defer if (!session_finished) {
_ = import_cli.finishSession(&pipes, streams, dependencies.git, request.quiet, true) catch false;
};
var tree_store = LogTreeStore{ .gpa = gpa, .log = log };
defer tree_store.deinitCache();
var importer = try git_import.Importer.init(gpa, log, tree_store.treeStore(), .{
.submanifest = switch (recorded.settings.submanifest) {
.flat => .flat,
.tree => |k| .{ .tree = k },
},
.attribution = switch (recorded.settings.attribution) {
.committer => .committer,
.author => .author,
},
});
defer importer.deinit();
for (recorded.commits) |commit| {
try importer.seedKnownCommit(commit.oid, commit.checkin, commit.branch, commit.depth, commit.height);
}
var reader = fast_export.Reader.init(gpa, pipes.stdout, .{
.max_line = 1024 * 1024,
.max_data = 1024 * 1024 * 1024,
.max_path = 4096,
.max_events = std.math.maxInt(u64),
.max_total_data = dependencies.limits.max_export_payload_bytes,
.max_command_arena_bytes = dependencies.limits.max_command_arena_bytes,
});
defer reader.deinit();
var diagnostic: import_cli.LimitDiagnostic = .{};
import_cli.consumeExport(&reader, &importer, dependencies.limits, &diagnostic, .{
.streams = streams,
.io = io,
.quiet = request.quiet,
.commit_total = pipes.commit_total,
}) catch |err| {
const scripted_child_failed = pipes.exit_code_ref == null and import_cli.terminalExitCode(&pipes) != 0;
const child_finished = import_cli.finishSession(&pipes, streams, dependencies.git, request.quiet, true) catch |lifecycle_err| {
session_finished = true;
return lifecycle_err;
};
session_finished = true;
diagnostic.write(streams.err);
if (scripted_child_failed or (child_finished and import_cli.terminalExitCode(&pipes) != 0)) return error.GitExportFailed;
return err;
};
_ = import_cli.finishSession(&pipes, streams, dependencies.git, request.quiet, false) catch |err| {
session_finished = true;
return err;
};
session_finished = true;
if (import_cli.terminalExitCode(&pipes) != 0) return error.GitExportFailed;
// Production reads come from the frozen private capture, so this compares
// that capture with itself; it still guards factories that describe a
// live source, and ensures every recorded ref resolves in staged artifacts.
var after_source = try dependencies.git.vtable.describe_source.?(dependencies.git.context, gpa, request.source);
defer after_source.deinit(gpa);
if (after_source.refs.len != identity.refs.len or !std.mem.eql(u8, after_source.object_format, identity.object_format)) return error.SourceChanged;
for (identity.refs) |before| {
const matched = for (after_source.refs) |after| {
if (std.mem.eql(u8, before.name, after.name) and std.mem.eql(u8, before.oid, after.oid)) break true;
} else false;
if (!matched) return error.SourceChanged;
}
// The classifier's per-commit state is inherited along first parents,
// which the stream no longer carries, so its verdicts cannot be trusted
// here; a fresh import of the whole history is the only honest answer.
if (importer.mirrorCensus().commits_with_candidates != 0) return error.MirrorMetadataInNewCommits;
// The primary label is pinned by validated destination configuration.
// A default ref can move onto a held side-branch commit without changing
// the destination's mainline label.
const selected_default = request.migrate_default_branch orelse recorded.settings.default_branch;
const primary_ref = try std.fmt.allocPrint(gpa, "refs/heads/{s}", .{selected_default});
defer gpa.free(primary_ref);
var destination_config = (try log.readConfig(gpa)) orelse return error.ConfigMissing;
defer destination_config.deinit(gpa);
const primary_label = destination_config.config.default_branch;
var finalized = try importer.finalizeRefs(.{
.default_branch = selected_default,
.primary_label = primary_label,
.symbolic_controls = false,
});
defer finalized.deinit(gpa);
if (recorded.settings.submanifest == .flat and finalized.stored_submanifest) return error.SubmanifestEmittedUnderFlatImport;
const added = importer.commitRecordCount();
var outcome: Outcome = .{ .added_commits = added, .notes = .empty };
errdefer outcome.deinit(gpa);
for (finalized.controls) |control| try import_cli.putSha1Artifact(log, control);
// Names are reconciled against what the source publishes now, not the
// stream: a ref on a commit the destination already holds never appears
// in the stream, an annotated tag on one is dropped from it outright.
// Every control written here is dated now, to the millisecond, so a name
// that moves, or returns to a check-in it once left, outranks the control
// before it even within one second; a name that did not change writes
// nothing. The main branch's own `sym-` name is never touched: it
// propagates from the branch control, and cancelling it on a tip the
// head left would take the branch itself off that check-in.
var now_buf: [23]u8 = undefined;
const now_ns = std.Io.Timestamp.now(io, .real).nanoseconds;
const now = cli.formatWriterTimestampMs(@intCast(@divFloor(now_ns, std.time.ns_per_s)), @intCast(@divFloor(@mod(now_ns, std.time.ns_per_s), std.time.ns_per_ms)), &now_buf);
var refs: std.ArrayList(map_file.RefRecord) = .empty;
defer refs.deinit(gpa);
var upstream_names: std.StringHashMapUnmanaged(void) = .empty;
defer upstream_names.deinit(gpa);
for (identity.refs) |ref| {
try upstream_names.put(gpa, ref.name, {});
const target = checkinOf(map, &importer, ref.oid) orelse return error.CapturedRefNotStaged;
try refs.append(gpa, .{ .name = ref.name, .oid = ref.oid });
const old = map.lookupRef(ref.name);
const ref_changed = if (old) |before| !std.mem.eql(u8, before.oid, ref.oid) else true;
const selected_now = request.migrate_default_branch != null and
!std.mem.eql(u8, selected_default, recorded.settings.default_branch) and
std.mem.eql(u8, ref.name, primary_ref);
if (!ref_changed and !selected_now) continue;
if (ref_changed) {
const canonical = try git_import.canonicalRefName(gpa, ref.name);
defer gpa.free(canonical);
if (old) |before| if (map.lookupOid(before.oid)) |left| {
try putSymbolic(log, &importer, left.checkin, canonical, .cancel, now);
if (std.mem.startsWith(u8, ref.name, "refs/heads/") and !firstParentLineReaches(&importer, target, left.checkin)) {
const note = try std.fmt.allocPrint(gpa, "head '{s}' moved to {s}; the check-ins it left behind stay", .{ ref.name["refs/heads/".len..], target[0..@min(target.len, 12)] });
errdefer gpa.free(note);
try outcome.notes.append(gpa, note);
}
};
try putSymbolic(log, &importer, target, canonical, .add, now);
}
// Selecting an already-recorded ref still changes its primary role.
// The main branch's own name propagates from its branch control, so
// it needs no help unless the head landed on a check-in another
// branch already labelled; then only a direct tag names it there.
if (std.mem.eql(u8, ref.name, primary_ref)) if (map.lookupOid(ref.oid)) |held| if (!std.mem.eql(u8, held.branch, primary_label)) {
const alias = try std.fmt.allocPrint(gpa, "sym-{s}", .{primary_label});
defer gpa.free(alias);
try putSymbolic(log, &importer, target, alias, .add, now);
};
}
for (recorded.refs) |old| {
if (upstream_names.contains(old.name)) continue;
const left = map.lookupOid(old.oid) orelse continue;
const canonical = try git_import.canonicalRefName(gpa, old.name);
defer gpa.free(canonical);
try putSymbolic(log, &importer, left.checkin, canonical, .cancel, now);
if (std.mem.startsWith(u8, old.name, "refs/heads/")) {
const note = try std.fmt.allocPrint(gpa, "head '{s}' is gone upstream; its check-ins stay", .{old.name["refs/heads/".len..]});
errdefer gpa.free(note);
try outcome.notes.append(gpa, note);
}
}
// Trees were stored for the first time: the latch the fresh import sets
// from its own emission has to follow, or the store lies about itself.
if (finalized.stored_submanifest) {
var current = (try log.readConfig(gpa)) orelse return error.ConfigMissing;
defer current.deinit(gpa);
if (!current.config.seen_submanifest) {
var updated = current.config;
updated.seen_submanifest = true;
try log.putConfig(updated);
}
}
try hooks.check(.before_into_commit);
try log.commit();
try hooks.check(.before_into_map);
// The new map: every commit recorded before plus the ones added, every
// ref the source publishes now.
var commits = try gpa.alloc(map_file.CommitRecord, recorded.commits.len + added);
defer gpa.free(commits);
@memcpy(commits[0..recorded.commits.len], recorded.commits);
for (0..added) |index| {
const record = try importer.commitRecord(index);
commits[recorded.commits.len + index] = .{
.oid = record.oid,
.checkin = record.hash,
.branch = record.branch,
.depth = record.depth,
.height = record.tree_height,
};
}
var token: [32]u8 = undefined;
io.random(&token);
outcome.publication_token = token;
var updated_settings = recorded.settings;
updated_settings.default_branch = selected_default;
try map_file.write(gpa, io, destination, .{
.publication_token = token,
.store_size = log.size(),
.inventory_hash = try map_file.inventoryHash(gpa, log),
.primary_label = primary_label,
.object_format = recorded.object_format,
.roots = recorded.roots,
.settings = updated_settings,
.mirror_classifier_fired = false,
.artifact_count = log.artifactCount(),
.commits = commits,
.refs = refs.items,
});
return outcome;
}
/// The check-in a Git commit became: one this import added, or one the
/// destination recorded.
fn checkinOf(map: *const map_file.Map, importer: *const git_import.Importer, oid: []const u8) ?[]const u8 {
if (map.lookupOid(oid)) |record| return record.checkin;
for (0..importer.commitRecordCount()) |index| {
const record = importer.commitRecord(index) catch continue;
if (std.mem.eql(u8, record.oid, oid)) return record.hash;
}
return null;
}
fn putSymbolic(log: *logstore.LogStore, importer: *git_import.Importer, target: []const u8, canonical: []const u8, sign: enum { add, cancel }, now: []const u8) !void {
const bytes = switch (sign) {
.add => try importer.makeSymbolicAdd(target, canonical, now),
.cancel => try importer.makeSymbolicCancel(target, canonical, now),
};
defer importer.gpa.free(bytes);
try import_cli.putSha1Artifact(log, bytes);
}
/// Whether walking first parents from the new object `tip` reaches the
/// check-in `target` before leaving the objects this import added. A head
/// that advanced along its own line passes; one that was reset or rewritten
/// does not. A merge of the old tip into a new line reads as a move, which
/// is a safe over-report.
fn firstParentLineReaches(importer: *const git_import.Importer, tip: []const u8, target: []const u8) bool {
var cursor = importer.objectIndexOfHash(tip) orelse return false;
while (true) {
const object = importer.objectAt(cursor);
if (object.first_parent_index) |parent| {
cursor = parent;
continue;
}
const first = object.first_parent orelse return false;
return std.mem.eql(u8, first, target);
}
}