Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions contrib/repro-push-reparent-drops-work.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
#
# Reproduction: a push carrying more than one local revision into a branch head
# that has moved silently drops the other side's work.
#
# LORE=/path/to/lore LORE_SERVER=lore://127.0.0.1:41337 ./repro-push-reparent-drops-work.sh
#
# Needs a reachable loreserver with no auth and a lore CLI. Creates its own
# repository on that server and three working copies under a temp directory.
#
# What it does:
# A and B both clone the same head.
# A commits one file and pushes. -> the head moves
# B, still on the old head, commits TWO files and
# pushes once with --fast-forward-merge. -> both land
# A third clone then shows what the server really holds.
#
# Expected (correct): the third clone has A's file and both of B's.
# Observed (before the fix): A's file is gone, while A's commit is still in
# the history, so nothing looks wrong to anyone reading it.
#
# Why: in lore-revision/src/branch/push.rs the client bends a revision's
# parent pointer onto whatever the previous push returned, keeping its tree:
#
# if !current_latest.is_zero() && state.parent_self() != current_latest {
# state.set_parent_self(current_latest);
# }
#
# That is right when the server merely renumbered the revision we just sent
# (same content under a new signature). After a server-side fast-forward merge
# `current_latest` is a different revision carrying somebody else's work, and
# re-pointing at it makes B's stale tree look like a direct descendant. The
# server then takes the ordinary fast-forward path — no merge, no three-way
# diff, no conflict check — and the branch becomes B's tree.
#
# The guard `!current_latest.is_zero()` is why a single-revision push is safe:
# the block cannot fire for the first revision of a push, only from the second.

set -euo pipefail

LORE=${LORE:-lore}
: "${LORE_SERVER:?set LORE_SERVER, e.g. lore://127.0.0.1:41337}"
# merge - integrate with --fast-forward-merge (the shape that ships today)
# rebase - integrate with --rebase (linear history)
MODE=${MODE:-merge}
case "$MODE" in merge|rebase) ;; *) echo "MODE must be merge or rebase"; exit 2 ;; esac

WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
REPO="${LORE_SERVER%/}/reparent-repro-$$-$(date +%s)"

lore_in() { local wc=$1; shift; "$LORE" --repository "$wc" --no-pager "$@" </dev/null; }

add_commit() { # <wc> <file> <content> <message>
local wc=$1 file=$2 content=$3 msg=$4
printf '%s\n' "$content" > "$wc/$file"
lore_in "$wc" stage "$wc/$file" >/dev/null
lore_in "$wc" commit "$msg" >/dev/null
}

echo "repository: $REPO"
echo

# ── seed ────────────────────────────────────────────────────────────────────
"$LORE" --no-pager repository create "$REPO" --repository "$WORK/seed" </dev/null >/dev/null
add_commit "$WORK/seed" keep.txt seed "seed"
lore_in "$WORK/seed" push >/dev/null

# ── two clones off the same head ────────────────────────────────────────────
"$LORE" --no-pager clone "$REPO" "$WORK/a" </dev/null >/dev/null
"$LORE" --no-pager clone "$REPO" "$WORK/b" </dev/null >/dev/null

# ── A publishes, moving the head ────────────────────────────────────────────
add_commit "$WORK/a" from-a.txt "A only" "A publishes"
lore_in "$WORK/a" push >/dev/null
echo "A pushed from-a.txt"

# ── B, still on the old head, commits TWICE and pushes once ─────────────────
add_commit "$WORK/b" from-b1.txt "B one" "B one"
add_commit "$WORK/b" from-b2.txt "B two" "B two"
if [ "$MODE" = rebase ]; then
if ! lore_in "$WORK/b" push --rebase; then
echo
echo "RESULT: REFUSED — the server did not integrate the push."
echo " On a server that predates --rebase this is the expected answer:"
echo " it ignores the unknown field, sees no integration opt-in and"
echo " soft-rejects, rather than silently merging instead."
"$LORE" --no-pager clone "$REPO" "$WORK/c" </dev/null >/dev/null
[ -f "$WORK/c/from-a.txt" ] && echo " A's file is untouched on the server." || echo " UNEXPECTED: A's file is gone anyway."
exit 3
fi
echo "B pushed two revisions with --rebase"
else
lore_in "$WORK/b" push --fast-forward-merge >/dev/null
echo "B pushed two revisions with --fast-forward-merge"
fi
echo

# ── what does the server actually hold? ─────────────────────────────────────
"$LORE" --no-pager clone "$REPO" "$WORK/c" </dev/null >/dev/null
echo "files in a fresh clone:"
find "$WORK/c" -maxdepth 1 -mindepth 1 -exec basename {} \; | sort | sed 's/^/ /'
echo
echo "history (A's commit is still in it either way):"
lore_in "$WORK/c" history 6 | grep -E '^(Revision|Signature| )' | sed 's/^/ /'
echo

rc=0
for f in from-b1.txt from-b2.txt; do
[ -f "$WORK/c/$f" ] || { echo "UNEXPECTED: B's own $f is missing too"; rc=1; }
done
if [ "$MODE" = rebase ]; then
if lore_in "$WORK/c" history 6 | grep -q '^Merge '; then
echo "RESULT: NOT LINEAR — a rebase must not leave a merge revision"
rc=1
else
echo "(history is linear: no merge revision)"
fi
fi

if [ -f "$WORK/c/from-a.txt" ]; then
echo "RESULT: OK — A's file survived B's push"
else
echo "RESULT: LOST — A's file is gone from the branch, with no error reported"
rc=1
fi
exit $rc
3 changes: 3 additions & 0 deletions lore-capi/lore.h
Original file line number Diff line number Diff line change
Expand Up @@ -4291,6 +4291,9 @@ typedef struct lore_branch_push_args_t {
struct lore_string_t branch;
// Allow the server to fast-forward merge if the target branch head has moved
uint8_t fast_forward_merge;
// Allow the server to rebase onto the target branch head if it has moved,
// keeping the branch linear. Mutually exclusive with `fast_forward_merge`.
uint8_t rebase;
} lore_branch_push_args_t;

// Arguments for retrieving branch metadata (one key or all).
Expand Down
7 changes: 7 additions & 0 deletions lore-client/src/cli/commands/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ pub struct BranchPushArgs {
/// Allow the server to fast-forward merge if the target branch head has moved
#[clap(long)]
fast_forward_merge: bool,

/// Allow the server to rebase onto the target branch head if it has moved,
/// keeping the branch linear instead of recording a merge. A server that
/// does not support this refuses the push rather than merging instead.
#[clap(long, conflicts_with = "fast_forward_merge")]
rebase: bool,
}

#[derive(Args)]
Expand Down Expand Up @@ -818,6 +824,7 @@ pub fn handle_branch_push(globals: LoreGlobalArgs, args: &BranchPushArgs) -> u8
let push_args = LoreBranchPushArgs {
branch: args.name.clone().into(),
fast_forward_merge: args.fast_forward_merge.into(),
rebase: args.rebase.into(),
};

let debug = progress_debug();
Expand Down
14 changes: 14 additions & 0 deletions lore-proto/proto/lore/revision/v1/revision.proto
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ message BranchPushRequest {
// the server attempts a fast-forward merge. Mutually superseded by
// `force`.
bool fast_forward_merge = 4;
// When true and the new tip does not descend from the current tip,
// the server rebases it: the same three-way diff `fast_forward_merge`
// performs, but the result records only the current tip as its parent,
// so the branch stays linear and the pushed revision is not retained
// as a second parent. Mutually superseded by `force`; setting it
// together with `fast_forward_merge` is INVALID_ARGUMENT, because the
// two ask for different histories and silently picking one would hide
// which was applied.
//
// A server that predates this field ignores it and sees a push with
// no integration opt-in, so it soft-rejects rather than merging. That
// is deliberate: a client asking to rebase must never be answered
// with a merge it did not ask for.
bool rebase = 5;
}

// Response describing the resulting branch tip after a push.
Expand Down
15 changes: 15 additions & 0 deletions lore-proto/src/grpc/lore.revision.v1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,21 @@ pub struct BranchPushRequest {
/// `force`.
#[prost(bool, tag = "4")]
pub fast_forward_merge: bool,
/// When true and the new tip does not descend from the current tip,
/// the server rebases it: the same three-way diff `fast_forward_merge`
/// performs, but the result records only the current tip as its parent,
/// so the branch stays linear and the pushed revision is not retained
/// as a second parent. Mutually superseded by `force`; setting it
/// together with `fast_forward_merge` is INVALID_ARGUMENT, because the
/// two ask for different histories and silently picking one would hide
/// which was applied.
///
/// A server that predates this field ignores it and sees a push with
/// no integration opt-in, so it soft-rejects rather than merging. That
/// is deliberate: a client asking to rebase must never be answered
/// with a merge it did not ask for.
#[prost(bool, tag = "5")]
pub rebase: bool,
}
impl ::prost::Name for BranchPushRequest {
const NAME: &'static str = "BranchPushRequest";
Expand Down
1 change: 1 addition & 0 deletions lore-proto/tests/v1_revision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ fn v1_revision_field_shapes() {
revision_signature: _,
force: _,
fast_forward_merge: _,
rebase: _,
} = BranchPushRequest::default();
let BranchPushResponse {
revision_signature: _,
Expand Down
4 changes: 2 additions & 2 deletions lore-revision/src/branch/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4225,7 +4225,7 @@ async fn merge_into_link(
.forward::<MergeError>("pushing fragments")?;

let response = revision_protocol
.branch_push(target_branch, signature, false, false)
.branch_push(target_branch, signature, false, false, false)
.await
.forward::<MergeError>("pushing branch")?;

Expand Down Expand Up @@ -4616,7 +4616,7 @@ pub async fn merge_into(
.send();

let response = revision_protocol
.branch_push(branch, signature, false, false)
.branch_push(branch, signature, false, false, false)
.await
.forward::<MergeError>("pushing branch")?;

Expand Down
Loading
Loading