update-ref: implement interactive transaction handling

The git-update-ref(1) command can only handle queueing transactions
right now via its "--stdin" parameter, but there is no way for users to
handle the transaction itself in a more explicit way. E.g. in a
replicated scenario, one may imagine a coordinator that spawns
git-update-ref(1) for multiple repositories and only if all agree that
an update is possible will the coordinator send a commit. Such a
transactional session could look like

    > start
    < start: ok
    > update refs/heads/master $OLD $NEW
    > prepare
    < prepare: ok
    # All nodes have returned "ok"
    > commit
    < commit: ok

or

    > start
    < start: ok
    > create refs/heads/master $OLD $NEW
    > prepare
    < fatal: cannot lock ref 'refs/heads/master': reference already exists
    # On all other nodes:
    > abort
    < abort: ok

In order to allow for such transactional sessions, this commit
introduces four new commands for git-update-ref(1), which matches those
we have internally already with the exception of "start":

    - start: start a new transaction

    - prepare: prepare the transaction, that is try to lock all
               references and verify their current value matches the
               expected one

    - commit: explicitly commit a session, that is update references to
              match their new expected state

    - abort: abort a session and roll back all changes

By design, git-update-ref(1) will commit as soon as standard input is
being closed. While fine in a non-transactional world, it is definitely
unexpected in a transactional world. Because of this, as soon as any of
the new transactional commands is used, the default will change to
aborting without an explicit "commit". To avoid a race between queueing
updates and the first "prepare" that starts a transaction, the "start"
command has been added to start an explicit transaction.

Add some tests to exercise this new functionality.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
This commit is contained in:
Patrick Steinhardt 2020-04-02 09:10:02 +02:00 committed by Junio C Hamano
parent 94fd491a54
commit e48cf33b61
3 changed files with 255 additions and 8 deletions

View file

@ -66,6 +66,10 @@ performs all modifications together. Specify commands of the form:
delete SP <ref> [SP <oldvalue>] LF delete SP <ref> [SP <oldvalue>] LF
verify SP <ref> [SP <oldvalue>] LF verify SP <ref> [SP <oldvalue>] LF
option SP <opt> LF option SP <opt> LF
start LF
prepare LF
commit LF
abort LF
With `--create-reflog`, update-ref will create a reflog for each ref With `--create-reflog`, update-ref will create a reflog for each ref
even if one would not ordinarily be created. even if one would not ordinarily be created.
@ -83,6 +87,10 @@ quoting:
delete SP <ref> NUL [<oldvalue>] NUL delete SP <ref> NUL [<oldvalue>] NUL
verify SP <ref> NUL [<oldvalue>] NUL verify SP <ref> NUL [<oldvalue>] NUL
option SP <opt> NUL option SP <opt> NUL
start NUL
prepare NUL
commit NUL
abort NUL
In this format, use 40 "0" to specify a zero value, and use the empty In this format, use 40 "0" to specify a zero value, and use the empty
string to specify a missing value. string to specify a missing value.
@ -114,6 +122,24 @@ option::
The only valid option is `no-deref` to avoid dereferencing The only valid option is `no-deref` to avoid dereferencing
a symbolic ref. a symbolic ref.
start::
Start a transaction. In contrast to a non-transactional session, a
transaction will automatically abort if the session ends without an
explicit commit.
prepare::
Prepare to commit the transaction. This will create lock files for all
queued reference updates. If one reference could not be locked, the
transaction will be aborted.
commit::
Commit all reference updates queued for the transaction, ending the
transaction.
abort::
Abort the transaction, releasing all locks if the transaction is in
prepared state.
If all <ref>s can be locked with matching <oldvalue>s If all <ref>s can be locked with matching <oldvalue>s
simultaneously, all modifications are performed. Otherwise, no simultaneously, all modifications are performed. Otherwise, no
modifications are performed. Note that while each individual modifications are performed. Note that while each individual

View file

@ -312,21 +312,80 @@ static void parse_cmd_option(struct ref_transaction *transaction,
die("option unknown: %s", next); die("option unknown: %s", next);
} }
static void parse_cmd_start(struct ref_transaction *transaction,
const char *next, const char *end)
{
if (*next != line_termination)
die("start: extra input: %s", next);
puts("start: ok");
}
static void parse_cmd_prepare(struct ref_transaction *transaction,
const char *next, const char *end)
{
struct strbuf error = STRBUF_INIT;
if (*next != line_termination)
die("prepare: extra input: %s", next);
if (ref_transaction_prepare(transaction, &error))
die("prepare: %s", error.buf);
puts("prepare: ok");
}
static void parse_cmd_abort(struct ref_transaction *transaction,
const char *next, const char *end)
{
struct strbuf error = STRBUF_INIT;
if (*next != line_termination)
die("abort: extra input: %s", next);
if (ref_transaction_abort(transaction, &error))
die("abort: %s", error.buf);
puts("abort: ok");
}
static void parse_cmd_commit(struct ref_transaction *transaction,
const char *next, const char *end)
{
struct strbuf error = STRBUF_INIT;
if (*next != line_termination)
die("commit: extra input: %s", next);
if (ref_transaction_commit(transaction, &error))
die("commit: %s", error.buf);
puts("commit: ok");
ref_transaction_free(transaction);
}
enum update_refs_state {
/* Non-transactional state open for updates. */
UPDATE_REFS_OPEN,
/* A transaction has been started. */
UPDATE_REFS_STARTED,
/* References are locked and ready for commit */
UPDATE_REFS_PREPARED,
/* Transaction has been committed or closed. */
UPDATE_REFS_CLOSED,
};
static const struct parse_cmd { static const struct parse_cmd {
const char *prefix; const char *prefix;
void (*fn)(struct ref_transaction *, const char *, const char *); void (*fn)(struct ref_transaction *, const char *, const char *);
unsigned args; unsigned args;
enum update_refs_state state;
} command[] = { } command[] = {
{ "update", parse_cmd_update, 3 }, { "update", parse_cmd_update, 3, UPDATE_REFS_OPEN },
{ "create", parse_cmd_create, 2 }, { "create", parse_cmd_create, 2, UPDATE_REFS_OPEN },
{ "delete", parse_cmd_delete, 2 }, { "delete", parse_cmd_delete, 2, UPDATE_REFS_OPEN },
{ "verify", parse_cmd_verify, 2 }, { "verify", parse_cmd_verify, 2, UPDATE_REFS_OPEN },
{ "option", parse_cmd_option, 1 }, { "option", parse_cmd_option, 1, UPDATE_REFS_OPEN },
{ "start", parse_cmd_start, 0, UPDATE_REFS_STARTED },
{ "prepare", parse_cmd_prepare, 0, UPDATE_REFS_PREPARED },
{ "abort", parse_cmd_abort, 0, UPDATE_REFS_CLOSED },
{ "commit", parse_cmd_commit, 0, UPDATE_REFS_CLOSED },
}; };
static void update_refs_stdin(void) static void update_refs_stdin(void)
{ {
struct strbuf input = STRBUF_INIT, err = STRBUF_INIT; struct strbuf input = STRBUF_INIT, err = STRBUF_INIT;
enum update_refs_state state = UPDATE_REFS_OPEN;
struct ref_transaction *transaction; struct ref_transaction *transaction;
int i, j; int i, j;
@ -374,14 +433,45 @@ static void update_refs_stdin(void)
if (strbuf_appendwholeline(&input, stdin, line_termination)) if (strbuf_appendwholeline(&input, stdin, line_termination))
break; break;
switch (state) {
case UPDATE_REFS_OPEN:
case UPDATE_REFS_STARTED:
/* Do not downgrade a transaction to a non-transaction. */
if (cmd->state >= state)
state = cmd->state;
break;
case UPDATE_REFS_PREPARED:
if (cmd->state != UPDATE_REFS_CLOSED)
die("prepared transactions can only be closed");
state = cmd->state;
break;
case UPDATE_REFS_CLOSED:
die("transaction is closed");
break;
}
cmd->fn(transaction, input.buf + strlen(cmd->prefix) + !!cmd->args, cmd->fn(transaction, input.buf + strlen(cmd->prefix) + !!cmd->args,
input.buf + input.len); input.buf + input.len);
} }
if (ref_transaction_commit(transaction, &err)) switch (state) {
die("%s", err.buf); case UPDATE_REFS_OPEN:
/* Commit by default if no transaction was requested. */
if (ref_transaction_commit(transaction, &err))
die("%s", err.buf);
ref_transaction_free(transaction);
break;
case UPDATE_REFS_STARTED:
case UPDATE_REFS_PREPARED:
/* If using a transaction, we want to abort it. */
if (ref_transaction_abort(transaction, &err))
die("%s", err.buf);
break;
case UPDATE_REFS_CLOSED:
/* Otherwise no need to do anything, the transaction was closed already. */
break;
}
ref_transaction_free(transaction);
strbuf_release(&err); strbuf_release(&err);
strbuf_release(&input); strbuf_release(&input);
} }

View file

@ -1404,4 +1404,135 @@ test_expect_success 'handle per-worktree refs in refs/bisect' '
! test_cmp main-head worktree-head ! test_cmp main-head worktree-head
' '
test_expect_success 'transaction handles empty commit' '
cat >stdin <<-EOF &&
start
prepare
commit
EOF
git update-ref --stdin <stdin >actual &&
printf "%s: ok\n" start prepare commit >expect &&
test_cmp expect actual
'
test_expect_success 'transaction handles empty commit with missing prepare' '
cat >stdin <<-EOF &&
start
commit
EOF
git update-ref --stdin <stdin >actual &&
printf "%s: ok\n" start commit >expect &&
test_cmp expect actual
'
test_expect_success 'transaction handles sole commit' '
cat >stdin <<-EOF &&
commit
EOF
git update-ref --stdin <stdin >actual &&
printf "%s: ok\n" commit >expect &&
test_cmp expect actual
'
test_expect_success 'transaction handles empty abort' '
cat >stdin <<-EOF &&
start
prepare
abort
EOF
git update-ref --stdin <stdin >actual &&
printf "%s: ok\n" start prepare abort >expect &&
test_cmp expect actual
'
test_expect_success 'transaction exits on multiple aborts' '
cat >stdin <<-EOF &&
abort
abort
EOF
test_must_fail git update-ref --stdin <stdin >actual 2>err &&
printf "%s: ok\n" abort >expect &&
test_cmp expect actual &&
grep "fatal: transaction is closed" err
'
test_expect_success 'transaction exits on start after prepare' '
cat >stdin <<-EOF &&
prepare
start
EOF
test_must_fail git update-ref --stdin <stdin 2>err >actual &&
printf "%s: ok\n" prepare >expect &&
test_cmp expect actual &&
grep "fatal: prepared transactions can only be closed" err
'
test_expect_success 'transaction handles empty abort with missing prepare' '
cat >stdin <<-EOF &&
start
abort
EOF
git update-ref --stdin <stdin >actual &&
printf "%s: ok\n" start abort >expect &&
test_cmp expect actual
'
test_expect_success 'transaction handles sole abort' '
cat >stdin <<-EOF &&
abort
EOF
git update-ref --stdin <stdin >actual &&
printf "%s: ok\n" abort >expect &&
test_cmp expect actual
'
test_expect_success 'transaction can handle commit' '
cat >stdin <<-EOF &&
start
create $a HEAD
commit
EOF
git update-ref --stdin <stdin >actual &&
printf "%s: ok\n" start commit >expect &&
test_cmp expect actual &&
git rev-parse HEAD >expect &&
git rev-parse $a >actual &&
test_cmp expect actual
'
test_expect_success 'transaction can handle abort' '
cat >stdin <<-EOF &&
start
create $b HEAD
abort
EOF
git update-ref --stdin <stdin >actual &&
printf "%s: ok\n" start abort >expect &&
test_cmp expect actual &&
test_path_is_missing .git/$b
'
test_expect_success 'transaction aborts by default' '
cat >stdin <<-EOF &&
start
create $b HEAD
EOF
git update-ref --stdin <stdin >actual &&
printf "%s: ok\n" start >expect &&
test_cmp expect actual &&
test_path_is_missing .git/$b
'
test_expect_success 'transaction with prepare aborts by default' '
cat >stdin <<-EOF &&
start
create $b HEAD
prepare
EOF
git update-ref --stdin <stdin >actual &&
printf "%s: ok\n" start prepare >expect &&
test_cmp expect actual &&
test_path_is_missing .git/$b
'
test_done test_done