1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
//! bl-22dd — sync the checkout that owns the integration branch after a
//! plumbing `update-ref` moved it underneath them.
//!
//! Landing a commit on a branch has four independent effects: write the
//! objects, move the ref, append a reflog entry, and update the index + working
//! tree of the checkout on that branch. The squash delivers via `commit-tree` +
//! `update-ref` from the project gitdir ([`crate::delivery_repo`]) — deliberate
//! plumbing, so it never disturbs the working tree of an unrelated checkout — but
//! that very plumbing SKIPS the fourth effect for the checkout that owns
//! `integration`. The ref advances while that checkout's index + working tree
//! stay at the pre-delivery tree, so `git status` there reports the whole
//! delivered diff as a phantom *staged* change (the user's primary checkout, the
//! one they work in). git refuses to merge/checkout a branch checked out in
//! another worktree, which is why the squash uses `update-ref` to bypass the
//! guard — and bypassing the guard also bypasses the working-tree update it
//! protects. [`Project::reconcile`] restores that fourth effect, separately and
//! idempotently, so the ref-flip stays the atomic BINDING commit point (§14) and
//! a crash between the two is a transient state the next run heals, not manual
//! cleanup.
//!
//! It restores it the way `git checkout` itself does: the two-way checkout
//! merge `git read-tree -m -u <old> <tip>` (bl-b69e). That updates exactly the
//! files the delivery changed, carries every other local edit forward, and
//! REFUSES atomically — touching nothing — when a delivered file is also edited
//! locally. An earlier gate (bl-22dd) acted only on a checkout pristine at
//! `HEAD^`, so one unrelated edit anywhere left the whole delivery showing as a
//! phantom staged revert; git's own gate is finer and just as safe.
use std::io;
use std::path::{Path, PathBuf};
use crate::delivery_repo::Project;
impl Project {
/// Carry every checkout that owns `integration` (the ref this delivery
/// moved) forward to it, healing the bl-22dd phantom; a checkout that
/// cannot be carried safely is left untouched with one `bl-delivery:` line
/// on stderr. NEVER fails the close over a checkout's state. Acts only on
/// checkouts OF that ref — the root on `main` for a flat close, the epic's
/// worktree for a nested one (bl-7b71); the closing ball's own `work/<id>`
/// and every sibling's sit on their own branches, so [`Self::checkouts_on`]
/// excludes them.
pub(crate) fn reconcile(&self, integration: &str) -> io::Result<()> {
for ck in self.checkouts_on(integration)? {
if let Some(warning) = Self::carry_forward(&ck, integration)? {
eprintln!("bl-delivery: {warning}");
}
}
Ok(())
}
/// One checkout on `branch`, step by step; `Some` is the warning to print.
///
/// 1. Refresh stat info, so a restatted-but-unchanged file is not "not
/// uptodate", then take the INDEX tree (`write-tree`). The index is the
/// true record of where the checkout stands. Unmerged entries make
/// `write-tree` fail: a conflict in progress is the human's, skip.
/// 2. Index tree == the tip's tree ⇒ already current (a retried close, a
/// crash healed by the last run, an unrelated worktree edit): skip.
/// This is the idempotence.
/// 3. `<old>` = the newest reflog entry of `branch` whose tree IS the index
/// tree. Not `HEAD^`: after an empty close or two unsynced deliveries it
/// names the wrong commit. Not the index tree itself: that would revert
/// staged edits. No match ⇒ staged changes sit on the checkout and no
/// `<old>` can be named safely: leave it, say so.
/// 4. `read-tree -m -u <old> <tip>`. On refusal git changed nothing: leave
/// it, and name the exact command to run once the edit is moved aside.
fn carry_forward(ck: &Path, branch: &str) -> io::Result<Option<String>> {
let at = ck.display();
let tip = Self::run(ck, &["rev-parse", &format!("refs/heads/{branch}")])?.trim().to_string();
Self::ok(ck, &["update-index", "-q", "--refresh"])?;
let Ok(index) = Self::run(ck, &["write-tree"]) else { return Ok(None) };
let index = index.trim();
if Self::run(ck, &["rev-parse", &format!("{tip}^{{tree}}")])?.trim() == index {
return Ok(None);
}
// A branch with no reflog at all fails `log -g`: same as no match.
let log = Self::run(ck, &["log", "-g", "--format=%H %T", &format!("refs/heads/{branch}")]);
let suffix = format!(" {index}");
let log = log.unwrap_or_default();
let Some(old) = log.lines().find_map(|l| l.strip_suffix(&suffix)) else {
return Ok(Some(format!(
"{at} is not at {branch}'s tip, but its index matches no recorded {branch} commit \
(staged changes?), so it was left alone; check `git -C {at} status` and bring it forward by hand"
)));
};
if Self::ok(ck, &["read-tree", "-m", "-u", old, &tip])? {
return Ok(None);
}
Ok(Some(format!(
"{at} was NOT carried forward to {branch}: a file the close changed is also modified there, \
and nothing was touched; move those edits aside, then run `git -C {at} read-tree -m -u {old} {tip}`"
)))
}
/// The non-bare checkouts that currently have `branch` checked out, read
/// from `git worktree list --porcelain`. Each worktree is a block led by a
/// `worktree <path>` line; the one that owns `branch` carries a `branch
/// refs/heads/<branch>` line. The bare root (a `bare` block, no `branch`
/// line) and every worktree on another branch — the agents' `work/<id>`
/// trees included — carry no matching line, so a caller acts only on the
/// checkout(s) that own `branch`.
fn checkouts_on(&self, branch: &str) -> io::Result<Vec<PathBuf>> {
let want = format!("branch refs/heads/{branch}");
let out = Self::run(&self.root, &["worktree", "list", "--porcelain"])?;
let mut paths = Vec::new();
let mut cur: Option<PathBuf> = None;
for line in out.lines() {
if let Some(p) = line.strip_prefix("worktree ") {
cur = Some(PathBuf::from(p));
} else if line == want {
if let Some(p) = cur.take() {
paths.push(p);
}
}
}
Ok(paths)
}
}
#[cfg(test)]
#[path = "delivery_reconcile_tests.rs"]
mod tests;