feat(git-sync): rebase local work onto origin on :gl divergence
Instead of refusing a divergence, :gl now replants the device's local commit(s) onto origin's tip (rebase_local_onto: splice the merge_base..HEAD paths from the card onto origin's tree, last-writer-wins per note, no content merge) and ends LocalAhead for :gp to publish. The branch ref moves last, after the merged tree is applied to the card, so a power-pull mid-rebase leaves HEAD at the old tip and the next :gl recomputes idempotently. Replaces PullOutcome::Diverged with Rebased(oid); the UI gives it the same stale-buffer refresh as Pulled and snackbars "rebased <oid> - :gp to publish".
This commit is contained in:
@@ -129,21 +129,25 @@ pub enum PublishOutcome {
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Result of a `:gl` pull attempt. Fast-forward only, by design: this device
|
||||
/// never merges — a divergence is surfaced and left for a machine with a real
|
||||
/// git to resolve.
|
||||
/// Result of a `:gl` pull attempt. The device never does a content merge; a
|
||||
/// clean fast-forward applies origin directly, and a divergence is resolved by
|
||||
/// rebasing our local commit(s) onto origin (last-writer-wins per note) rather
|
||||
/// than left for a computer.
|
||||
pub enum PullOutcome {
|
||||
/// Fast-forwarded onto origin's tip. Carries the short commit id; the UI
|
||||
/// must treat every tracked file as possibly rewritten (reload buffers,
|
||||
/// re-walk the palette list).
|
||||
Pulled(String),
|
||||
/// Histories diverged: origin's changes were integrated and our local
|
||||
/// commit(s) replanted on top (a rebase, not a merge). The working copy
|
||||
/// moved — same UI refresh as `Pulled` — and the device is now `LocalAhead`,
|
||||
/// so the user finishes with `:gp`. Carries the rebased commit's short id.
|
||||
Rebased(String),
|
||||
/// Origin's tip is our HEAD — nothing to pull.
|
||||
UpToDate,
|
||||
/// We are strictly ahead of origin (e.g. a stranded commit whose push
|
||||
/// failed) — nothing to pull; the next `:gp` publishes it.
|
||||
LocalAhead,
|
||||
/// Local and remote histories diverged. Refused — no merge on the device.
|
||||
Diverged,
|
||||
/// Something failed; short reason for the panel (full error is logged).
|
||||
Failed(String),
|
||||
}
|
||||
@@ -761,10 +765,12 @@ fn update_tracking(repo: &Repository, branch: &str, tip: Oid) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open `/sd/repo`, fetch origin, and **fast-forward only** — never a merge.
|
||||
/// The four non-failure shapes map to [`PullOutcome`]: already current, we're
|
||||
/// strictly ahead (a stranded commit — `:gp`'s job), a clean fast-forward,
|
||||
/// or a divergence (refused; a machine with a real git resolves it).
|
||||
/// Open `/sd/repo`, fetch origin, and integrate — **fast-forward when we can,
|
||||
/// rebase when we must**, never a content merge. The non-failure shapes map to
|
||||
/// [`PullOutcome`]: already current, we're strictly ahead (a stranded commit —
|
||||
/// `:gp`'s job), a clean fast-forward, or a divergence — where instead of
|
||||
/// refusing we replant our local commit(s) onto origin ([`rebase_local_onto`])
|
||||
/// and end `LocalAhead` for `:gp` to publish.
|
||||
///
|
||||
/// The fast-forward is checkout-then-ref-move, with a **SAFE** checkout: it
|
||||
/// refuses to overwrite a working-copy file whose content differs from HEAD's.
|
||||
@@ -856,12 +862,62 @@ fn pull_once() -> Result<PullOutcome> {
|
||||
.graph_descendant_of(theirs, head)
|
||||
.context("descendant check (fast-forward)")?
|
||||
{
|
||||
log::warn!(
|
||||
"pull: origin {} and HEAD {} diverged — refusing (ff-only, no merge on the device)",
|
||||
// Diverged: both sides moved. Rather than refuse, replant our local
|
||||
// commit(s) onto origin's tip so a plain `:gp` publishes them — no
|
||||
// computer needed. The branch ref moves LAST (after the card reflects
|
||||
// the rebased tree), so a power-pull mid-rebase leaves HEAD at the old
|
||||
// tip and the next `:gl` recomputes the identical commit idempotently.
|
||||
log::info!(
|
||||
"pull: origin {} and HEAD {} diverged — rebasing local work onto origin (last-writer-wins, no merge)",
|
||||
short(theirs),
|
||||
short(head)
|
||||
);
|
||||
return Ok(PullOutcome::Diverged);
|
||||
let t_rebase = Instant::now();
|
||||
let rebased = rebase_local_onto(&repo, head, theirs)?;
|
||||
let rebase_ms = t_rebase.elapsed().as_millis();
|
||||
|
||||
// Nothing of ours survived the replay (our edits were already upstream):
|
||||
// collapse to a plain fast-forward onto origin.
|
||||
if rebased == theirs {
|
||||
let t_apply = Instant::now();
|
||||
let changed = apply_tree_diff(&repo, head, theirs)?;
|
||||
repo.reference(
|
||||
&format!("refs/heads/{branch}"),
|
||||
theirs,
|
||||
true,
|
||||
"typoena pull: fast-forward (local work already upstream)",
|
||||
)
|
||||
.context("fast-forwarding the branch ref")?;
|
||||
log::info!(
|
||||
"pull: local work already upstream — fast-forwarded {branch} to {} — apply {}ms ({changed} file(s))",
|
||||
short(theirs),
|
||||
t_apply.elapsed().as_millis()
|
||||
);
|
||||
return Ok(PullOutcome::Pulled(short(theirs)));
|
||||
}
|
||||
|
||||
// Bring the card from our old tree to the rebased tree: origin's
|
||||
// remote-only changes are written, our own edits are already on disk
|
||||
// (unchanged in the diff, so untouched). Ref moves last.
|
||||
let t_apply = Instant::now();
|
||||
let changed = apply_tree_diff(&repo, head, rebased)?;
|
||||
repo.reference(
|
||||
&format!("refs/heads/{branch}"),
|
||||
rebased,
|
||||
true,
|
||||
"typoena pull: rebase local onto origin",
|
||||
)
|
||||
.context("moving the branch ref to the rebased commit")?;
|
||||
log::info!(
|
||||
"pull: rebased {} onto origin {} -> {} — rebase {rebase_ms}ms, apply {}ms ({changed} file(s)), free heap {} ({} internal)",
|
||||
short(head),
|
||||
short(theirs),
|
||||
short(rebased),
|
||||
t_apply.elapsed().as_millis(),
|
||||
free_heap(),
|
||||
internal_free_heap()
|
||||
);
|
||||
return Ok(PullOutcome::Rebased(short(rebased)));
|
||||
}
|
||||
|
||||
let t_co = Instant::now();
|
||||
@@ -1012,6 +1068,90 @@ fn apply_tree_diff(repo: &Repository, head: Oid, theirs: Oid) -> Result<usize> {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Rebase the device's local-only work onto origin's tip and return the new
|
||||
/// commit id — a single squashed commit whose tree is origin's tree with our
|
||||
/// edits spliced back on top. This is `:gl`'s answer to a divergence (a
|
||||
/// stranded local commit while origin also moved): rather than refuse and send
|
||||
/// the user to a computer, we replant our work on the new base so a plain `:gp`
|
||||
/// publishes it.
|
||||
///
|
||||
/// Last-writer-wins by design, exactly like publish's post-rejection reconcile
|
||||
/// ([`reconcile_onto_origin`]): the replay set is the paths our side changed
|
||||
/// since the fork point (`merge_base..head`), each spliced from the **card**
|
||||
/// (the source of truth) onto origin's tree. A note both sides edited resolves
|
||||
/// to ours; every remote-only change rides along by OID from origin's tree.
|
||||
/// It is a rebase of one squashed commit, not a content merge — the device
|
||||
/// still has no merge engine (that stays increment-B work).
|
||||
///
|
||||
/// Returns `theirs` unchanged when nothing of ours survives the replay (our
|
||||
/// edits were already upstream) so the caller can collapse to a fast-forward
|
||||
/// instead of writing an empty commit. The commit is created with
|
||||
/// `commit(None, …)`: it does **not** move the branch ref. The caller applies
|
||||
/// the merged tree to the card and moves the ref last, so a power-pull
|
||||
/// mid-rebase leaves HEAD at the old tip and the next `:gl` recomputes the
|
||||
/// identical commit idempotently.
|
||||
fn rebase_local_onto(repo: &Repository, head: Oid, theirs: Oid) -> Result<Oid> {
|
||||
let base = repo
|
||||
.merge_base(head, theirs)
|
||||
.context("finding the merge-base to rebase onto origin")?;
|
||||
let base_tree = repo.find_commit(base)?.tree().context("merge-base tree")?;
|
||||
let head_tree = repo.find_commit(head)?.tree().context("HEAD tree")?;
|
||||
let their_commit = repo.find_commit(theirs)?;
|
||||
let their_tree = their_commit.tree().context("origin tree")?;
|
||||
let their_tree_id = their_tree.id();
|
||||
|
||||
// Our side's changes since the fork are the replay set: splice each onto
|
||||
// origin's tree, read from the card (a path missing on disk splices out — a
|
||||
// local delete), mirroring stage_and_commit's working-tree-as-truth model.
|
||||
let diff = repo
|
||||
.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), None)
|
||||
.context("diffing merge-base..HEAD for the replay set")?;
|
||||
let mut paths: BTreeSet<String> = BTreeSet::new();
|
||||
for d in diff.deltas() {
|
||||
if let Some(rel) = d
|
||||
.new_file()
|
||||
.path()
|
||||
.or_else(|| d.old_file().path())
|
||||
.and_then(|p| p.to_str())
|
||||
{
|
||||
paths.insert(rel.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut tree = their_tree;
|
||||
for path in &paths {
|
||||
// Media is never a device commit (the splice stages journal paths only,
|
||||
// and the card is text-only) — but reading a stray 16 MB blob would OOM,
|
||||
// so skip it and keep origin's version, consistent with apply_tree_diff.
|
||||
if is_media_path(path) {
|
||||
log::warn!("rebase: skipping media path {path} in the replay set (kept origin's version)");
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let blob = match fs::read(format!("{REPO_DIR}/{path}")) {
|
||||
Ok(bytes) => Some(repo.blob(&bytes).with_context(|| format!("blob for {path}"))?),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, // local delete
|
||||
Err(e) => return Err(e).with_context(|| format!("reading {path}")),
|
||||
};
|
||||
let spliced = splice(repo, Some(&tree), &parts, blob)
|
||||
.with_context(|| format!("splicing {path} onto origin"))?;
|
||||
tree = repo.find_tree(spliced).context("loading spliced tree")?;
|
||||
}
|
||||
|
||||
// Nothing of ours survived (edits already upstream) — signal a fast-forward.
|
||||
if tree.id() == their_tree_id {
|
||||
return Ok(theirs);
|
||||
}
|
||||
|
||||
let sig = Signature::now(AUTHOR_NAME, AUTHOR_EMAIL).context("building signature")?;
|
||||
let message = format!("Typoena rebase onto origin — unix {}", now_unix());
|
||||
repo.commit(None, &sig, &sig, &message, &tree, &[&their_commit])
|
||||
.context("creating the rebased commit")
|
||||
}
|
||||
|
||||
/// Paths [`apply_tree_diff`] never writes, deletes, or belt-hashes: binary
|
||||
/// media the device can't render and can't afford to hold in RAM. Matched by
|
||||
/// extension, case-insensitive. Text-ish assets (svg, csv…) stay eligible —
|
||||
|
||||
@@ -340,17 +340,30 @@ fn main() -> anyhow::Result<()> {
|
||||
PublishOutcome::Failed(reason) => reason,
|
||||
}
|
||||
}
|
||||
GitOutcome::Pull(outcome) => match outcome {
|
||||
// The working copy moved under us: stale resident
|
||||
// buffers must re-read the disk. Clean parked buffers
|
||||
// are dropped (they reload on the next switch), the
|
||||
// clean active buffer is re-read now, and a RAM-dirty
|
||||
// buffer is left alone — its edits win, last-writer-
|
||||
// wins like the publish reconcile. The palette list is
|
||||
// re-walked in the background for files the pull added
|
||||
// or removed (it lands on `walk_rx` a few seconds
|
||||
// later, instead of stalling the UI for the walk).
|
||||
PullOutcome::Pulled(oid) => {
|
||||
GitOutcome::Pull(outcome) => {
|
||||
// Pulled and Rebased both move the working copy under us
|
||||
// (Rebased applies origin's tree *and* replants our commit
|
||||
// on top); LocalAhead / UpToDate leave the tree untouched.
|
||||
let moved_working_copy = matches!(
|
||||
outcome,
|
||||
PullOutcome::Pulled(_) | PullOutcome::Rebased(_)
|
||||
);
|
||||
let notice = match outcome {
|
||||
PullOutcome::Pulled(oid) => format!("pulled {oid}"),
|
||||
PullOutcome::Rebased(oid) => format!("rebased {oid} - :gp to publish"),
|
||||
PullOutcome::UpToDate => "up to date".to_string(),
|
||||
PullOutcome::LocalAhead => "ahead - :gp to publish".to_string(),
|
||||
PullOutcome::Failed(reason) => reason,
|
||||
};
|
||||
if moved_working_copy {
|
||||
// Stale resident buffers must re-read the disk. Clean
|
||||
// parked buffers are dropped (they reload on the next
|
||||
// switch), the clean active buffer is re-read now, and
|
||||
// a RAM-dirty buffer is left alone — its edits win,
|
||||
// last-writer-wins like the publish reconcile. The
|
||||
// palette list is re-walked in the background for files
|
||||
// the pull added or removed (it lands on `walk_rx` a
|
||||
// few seconds later, instead of stalling the UI).
|
||||
ed.drop_clean_parked();
|
||||
if ed.dirty() {
|
||||
log::info!(
|
||||
@@ -367,13 +380,9 @@ fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
spawn_file_walk(walk_tx.clone());
|
||||
format!("pulled {oid}")
|
||||
}
|
||||
PullOutcome::UpToDate => "up to date".to_string(),
|
||||
PullOutcome::LocalAhead => "ahead - :gp to publish".to_string(),
|
||||
PullOutcome::Diverged => "diverged - resolve on a computer".to_string(),
|
||||
PullOutcome::Failed(reason) => reason,
|
||||
},
|
||||
notice
|
||||
}
|
||||
};
|
||||
ed.set_notice(notice);
|
||||
ed.draw_into(&mut back, true);
|
||||
|
||||
Reference in New Issue
Block a user