Skip to content

Commit 02e1d29

Browse files
committed
status: preserve authenticated proofs across owned index writes
b65fc91 (status: retain the identity of an index it rewrites, 2026-08-17) lets status keep a race-proof receipt for an index it rewrites. Receipt preparation duplicates the writer descriptor so it can hash the final bytes, and requires the in-memory checksum to match the configured null trailer. Three owned write paths can leave an otherwise valid index without a usable clean proof. A worktree-update repair first writes a checksummed provisional index, then reopens the lockfile write-only before the final skipHash write while the index still records the provisional checksum. Receipt preparation rejects both states, so scoped stash cannot publish a sidecar for the index it installs. Worktree add creates its linked index before the new worktree has a closed FSMonitor provider epoch. The index has FSMonitor and untracked-cache extensions, but lacks the authenticated clean-config proof. Later read-only status processes remain correct, but cannot persist that proof and repeat the full fallback on every invocation. A clean non-fast-forward merge repairs its authenticated index proof before committing, but leaves the existing sidecar bound to the old index and HEAD tree. The next read-only status rejects it with a fast-index-mismatch and scans the semantic manifest. Only a later writable status can replace the stale sidecar. Add a read-write reopen operation only for the provisional index lock so receipt preparation can read the final index. Fall back to the original write-only reopen when read access is unavailable, allowing the write to succeed without a receipt. Finish provisional writes through a cold helper that restores the null object ID before the receipt-aware write. After worktree add successfully runs the post-checkout hook, run one silent status to establish the provider epoch and persist the complete proof. Do so only when the caller permits optional locks; never override an explicit --no-optional-locks request. Factor the best-effort sidecar reissue used by rebase into wt-status. After a successful merge commit has installed its final HEAD and index, use that helper to authenticate the settled state. Sidecar failure still falls back to ordinary status and never changes the merge result. Cover receipt publication and adoption after scoped stash, preserve the generic write-only tempfile contract, and require worktree add to honor post-checkout index writes and disabled optional locks. Also require a clean non-fast-forward merge to publish a sidecar that its next read-only status can consume.
1 parent 5e6128e commit 02e1d29

16 files changed

Lines changed: 296 additions & 46 deletions

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2580,6 +2580,8 @@ LIBS = $(filter-out %.o, $(GITLIBS)) $(EXTLIBS)
25802580

25812581
BASIC_CFLAGS += $(COMPAT_CFLAGS)
25822582
LIB_OBJS += $(COMPAT_OBJS)
2583+
# Keep provisional-index glue after every existing library object.
2584+
LIB_OBJS += clean-status-index-provisional.o
25832585

25842586
# Quote for C
25852587

builtin/merge.c

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,6 +1431,7 @@ int cmd_merge(int argc,
14311431
struct strbuf buf = STRBUF_INIT;
14321432
int i, ret = 0, head_subsumed;
14331433
int best_cnt = -1, merge_was_ok = 0, automerge_was_ok = 0;
1434+
int merge_committed = 0, reissue_sidecar = 0;
14341435
int repair_after_merge = 0;
14351436
struct commit_list *common = NULL;
14361437
const char *best_strategy = NULL, *wt_strategy = NULL;
@@ -1536,6 +1537,8 @@ int cmd_merge(int argc,
15361537
!clean_status_config_read_repository(the_repository, &clean_digest)) {
15371538
clean_status_enable_external_history(the_repository);
15381539
clean_status_set_config_digest(the_repository, &clean_digest);
1540+
reissue_sidecar =
1541+
wt_status_clean_sidecar_present(the_repository);
15391542
}
15401543

15411544
if (repo_read_index_unmerged(the_repository))
@@ -1800,6 +1803,8 @@ int cmd_merge(int argc,
18001803
&remoteheads->item->object.oid)) {
18011804
ret = merge_trivial(head_commit, remoteheads,
18021805
repair_after_merge);
1806+
if (!ret)
1807+
merge_committed = 1;
18031808
goto done;
18041809
}
18051810
printf(_("Nope.\n"));
@@ -1909,6 +1914,8 @@ int cmd_merge(int argc,
19091914
ret = finish_automerge(head_commit, head_subsumed,
19101915
common, remoteheads,
19111916
&result_tree, wt_strategy);
1917+
if (!ret)
1918+
merge_committed = 1;
19121919
goto done;
19131920
}
19141921

@@ -1957,6 +1964,11 @@ int cmd_merge(int argc,
19571964
printf(_("When finished, apply stashed changes with `git stash pop`\n"));
19581965

19591966
done:
1967+
if (!ret && merge_committed && reissue_sidecar &&
1968+
repair_after_merge && use_optional_locks() &&
1969+
!hook_exists(the_repository, "post-index-change"))
1970+
wt_status_reissue_clean_sidecar_after_worktree_update(
1971+
the_repository, repair_after_merge, &clean_digest);
19601972
if (!automerge_was_ok) {
19611973
commit_list_free(common);
19621974
commit_list_free(remoteheads);

builtin/worktree.c

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include "copy.h"
1010
#include "dir.h"
1111
#include "environment.h"
12+
#include "fsmonitor-settings.h"
1213
#include "gettext.h"
1314
#include "hex.h"
1415
#include "object-file.h"
@@ -21,12 +22,14 @@
2122
#include "refs.h"
2223
#include "remote.h"
2324
#include "run-command.h"
25+
#include "repo-settings.h"
2426
#include "hook.h"
2527
#include "sigchain.h"
2628
#include "submodule.h"
2729
#include "utf8.h"
2830
#include "worktree.h"
2931
#include "quote.h"
32+
#include "trace2.h"
3033

3134
#define BUILTIN_WORKTREE_ADD_USAGE \
3235
N_("git worktree add [-f] [--detach] [--checkout] [--lock [--reason <string>]]\n" \
@@ -409,6 +412,31 @@ static int checkout_worktree(const struct add_opts *opts,
409412
return run_command(&cp);
410413
}
411414

415+
static void prime_worktree_clean_status_proof(const char *path)
416+
{
417+
struct child_process cp = CHILD_PROCESS_INIT;
418+
int ret;
419+
420+
/*
421+
* The checkout creates the linked index before it has a provider epoch
422+
* from which to certify the worktree. Establish that epoch while this
423+
* writer can still update the index; a later read-only status cannot
424+
* persist the missing proof.
425+
*/
426+
cp.git_cmd = 1;
427+
cp.dir = path;
428+
cp.no_stdin = 1;
429+
cp.no_stdout = 1;
430+
cp.no_stderr = 1;
431+
strvec_pushl(&cp.args, "status", "--porcelain=v2",
432+
"--untracked-files=normal", NULL);
433+
strvec_push(&cp.env, GIT_DIR_ENVIRONMENT);
434+
strvec_push(&cp.env, GIT_WORK_TREE_ENVIRONMENT);
435+
ret = run_command(&cp);
436+
trace2_data_intmax("worktree", the_repository,
437+
"add/clean-status-primed", !ret);
438+
}
439+
412440
static int make_worktree_orphan(const char * ref, const struct add_opts *opts,
413441
struct strvec *child_env)
414442
{
@@ -593,7 +621,6 @@ static int add_worktree(const char *path, const char *refname,
593621
if (opts->checkout &&
594622
(ret = checkout_worktree(opts, &child_env)))
595623
goto done;
596-
597624
is_junk = 0;
598625
FREE_AND_NULL(junk_work_tree);
599626
FREE_AND_NULL(junk_git_dir);
@@ -622,6 +649,15 @@ static int add_worktree(const char *path, const char *refname,
622649

623650
ret = run_hooks_opt(the_repository, "post-checkout", &opt);
624651
}
652+
if (!ret && opts->checkout && use_optional_locks() &&
653+
fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC &&
654+
the_repository->settings.core_untracked_cache ==
655+
UNTRACKED_CACHE_WRITE &&
656+
!getenv(INDEX_ENVIRONMENT) &&
657+
!getenv(GIT_COMMON_DIR_ENVIRONMENT) &&
658+
!getenv(DB_ENVIRONMENT) &&
659+
!getenv(ALTERNATE_DB_ENVIRONMENT))
660+
prime_worktree_clean_status_proof(path);
625661

626662
strvec_clear(&child_env);
627663
strbuf_release(&sb);

clean-status-index-provisional.c

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#include "git-compat-util.h"
2+
#include "clean-status-index.h"
3+
#include "lockfile.h"
4+
#include "read-cache-ll.h"
5+
#include "repository.h"
6+
7+
int clean_status_write_index_after_provisional(
8+
struct index_state *istate, struct lock_file *lock, unsigned flags,
9+
struct clean_status_index_write_receipt *receipt)
10+
{
11+
/* Let receipt preparation authenticate the final null trailer. */
12+
if (istate->repo->settings.index_skip_hash)
13+
oidcpy(&istate->oid, istate->repo->hash_algo->null_oid);
14+
return write_locked_index_with_receipt(
15+
istate, lock, flags, receipt);
16+
}

clean-status-index.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include "hash.h"
66

77
struct index_state;
8+
struct lock_file;
89
struct repository;
910

1011
/*
@@ -52,6 +53,11 @@ int clean_status_index_adopt_write_receipt(
5253
void clean_status_index_write_receipt_release(
5354
struct clean_status_index_write_receipt *receipt);
5455

56+
/* Finish a provisional write without widening the ordinary status hot path. */
57+
int clean_status_write_index_after_provisional(
58+
struct index_state *istate, struct lock_file *lock, unsigned flags,
59+
struct clean_status_index_write_receipt *receipt);
60+
5561
int clean_status_index_snapshot_open(
5662
struct clean_status_index_snapshot *snapshot, const char *path,
5763
const struct git_hash_algo *algo);

lockfile.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,11 @@ static inline int reopen_lock_file(struct lock_file *lk)
344344
return reopen_tempfile(lk->tempfile);
345345
}
346346

347+
static inline int reopen_lock_file_for_readwrite(struct lock_file *lk)
348+
{
349+
return reopen_tempfile_for_readwrite(lk->tempfile);
350+
}
351+
347352
/*
348353
* Commit the change represented by `lk`: close the file descriptor
349354
* and/or file pointer if they are still open and rename the lockfile

meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ libgit_sources = [
340340
'clean-status-history.c',
341341
'clean-status-identity.c',
342342
'clean-status-index.c',
343+
'clean-status-index-provisional.c',
343344
'clean-status-manifest.c',
344345
'clean-status-sidecar.c',
345346
'clean-status-fast.c',

sequencer.c

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5616,46 +5616,6 @@ static int commit_staged_changes(struct repository *r,
56165616
return ret;
56175617
}
56185618

5619-
static void reissue_clean_sidecar_after_rebase(
5620-
struct repository *r, int had_full_proof,
5621-
const struct clean_status_config_digest *config)
5622-
{
5623-
struct lock_file lock = LOCK_INIT;
5624-
int repaired;
5625-
5626-
/* The rebase has completed; a missing sidecar safely falls back. */
5627-
if (repo_hold_locked_index(r, &lock, 0) < 0) {
5628-
trace2_data_string("status", r,
5629-
"clean-proof/writer-sidecar-skip",
5630-
"index-lock");
5631-
return;
5632-
}
5633-
/* A replayed commit may have replaced the index in a child process. */
5634-
discard_index(r->index);
5635-
if (repo_read_index(r) < 0) {
5636-
rollback_lock_file(&lock);
5637-
trace2_data_string("status", r,
5638-
"clean-proof/writer-sidecar-skip",
5639-
"index-read");
5640-
return;
5641-
}
5642-
if (!r->index->fsmonitor_token_valid) {
5643-
r->index->fsmonitor_has_run_once = 0;
5644-
refresh_fsmonitor(r->index);
5645-
}
5646-
repaired = wt_status_repair_fsmonitor_proof_after_update_with_sidecar(
5647-
r, &lock, had_full_proof, config);
5648-
if (repaired < 0) {
5649-
rollback_lock_file(&lock);
5650-
trace2_data_string("status", r,
5651-
"clean-proof/writer-sidecar-skip",
5652-
"proof-repair");
5653-
return;
5654-
}
5655-
if (!repaired)
5656-
rollback_lock_file(&lock);
5657-
}
5658-
56595619
int sequencer_continue(struct repository *r, struct replay_opts *opts)
56605620
{
56615621
struct todo_list todo_list = TODO_LIST_INIT;
@@ -5727,7 +5687,7 @@ int sequencer_continue(struct repository *r, struct replay_opts *opts)
57275687
struct clean_status_config_digest digest;
57285688

57295689
if (!clean_status_config_read_repository(r, &digest))
5730-
reissue_clean_sidecar_after_rebase(
5690+
wt_status_reissue_clean_sidecar_after_worktree_update(
57315691
r, had_full_proof, &digest);
57325692
}
57335693
release_todo_list:

t/helper/test-mktemp.c

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,28 @@
33
*/
44
#include "test-tool.h"
55
#include "git-compat-util.h"
6+
#include "tempfile.h"
67

78
int cmd__mktemp(int argc, const char **argv)
89
{
910
char *template;
1011
int fd;
12+
struct tempfile *tempfile;
13+
14+
if (argc == 3 && !strcmp(argv[1], "--reopen-write-only")) {
15+
tempfile = mks_tempfile_m(argv[2], 0200);
16+
if (!tempfile)
17+
die_errno("unable to create tempfile");
18+
if (close_tempfile_gently(tempfile))
19+
die_errno("unable to close tempfile");
20+
if (reopen_tempfile_for_readwrite(tempfile) >= 0)
21+
die("unexpectedly reopened write-only tempfile for reading");
22+
if (reopen_tempfile(tempfile) < 0)
23+
die_errno("unable to reopen write-only tempfile");
24+
if (delete_tempfile(&tempfile))
25+
die_errno("unable to delete tempfile");
26+
return 0;
27+
}
1128

1229
if (argc != 2)
1330
usage("Expected 1 parameter defining the temporary file template");

t/t0070-fundamental.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ test_expect_success POSIXPERM,SANITY 'mktemp to unwritable directory prints file
2121
test_grep "cannotwrite/test" err
2222
'
2323

24+
test_expect_success POSIXPERM,SANITY \
25+
'reopen tempfile retains its write-only contract' '
26+
test-tool mktemp --reopen-write-only write-only-XXXXXX
27+
'
28+
2429
test_expect_success 'git_mkstemps_mode does not fail if fd 0 is not open' '
2530
git commit --allow-empty -m message <&-
2631
'

0 commit comments

Comments
 (0)