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
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
//! Delivery-link resolution (SPEC ยง6).
//!
//! Each task carries a `delivered_in: Option<String>` hint pointing at
//! the squash-merge commit on main. Ground truth is the `[bl-xxxx]`
//! tag embedded in the commit message โ the hint is a cache.
//!
//! On read, `resolve` verifies the hint is still reachable from main
//! *and* still contains the tag. If either check fails, it falls back
//! to a tag scan on main. Survives rebase, amend, cherry-pick, and
//! filter-branch because the tag travels with the commit.
use crate::git;
use crate::task::Task;
use std::path::Path;
/// Output of a delivery-link resolution.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Delivery {
/// SHA of the delivering commit on main, if one could be resolved.
pub sha: Option<String>,
/// True when the task file's hint disagrees with the resolution
/// (hint pointed at a different SHA, or at nothing verifiable).
/// Callers that intend to persist corrections can check this to
/// decide whether to rewrite the task file.
pub hint_stale: bool,
}
/// Resolve the delivering commit for `task`. Consults the hint first,
/// falls back to a tag scan on `main_branch` โ the integration branch
/// the caller resolved through `Config::integration_branch` (the
/// single `target_branch` seam), so this stays a pure git query with
/// no config knowledge of its own. Returns an empty result if the git
/// state can't be queried (e.g., `repo_root` isn't a git repo, or the
/// branch doesn't exist).
pub fn resolve(repo_root: &Path, main_branch: &str, task: &Task) -> Delivery {
let tag = format!("[{}]", task.id);
if let Some(hint) = &task.delivered_in {
if git::git_is_ancestor(repo_root, hint, main_branch)
&& git::git_commit_subject(repo_root, hint)
.is_some_and(|s| s.contains(&tag))
{
return Delivery {
sha: Some(hint.clone()),
hint_stale: false,
};
}
// Hint doesn't verify โ fall through to the tag scan. Mark
// stale only if the tag scan finds a *different* answer.
let resolved = git::git_log_find_subject(repo_root, main_branch, &tag);
let stale = match (&resolved, hint) {
(Some(sha), h) => sha != h,
(None, _) => true,
};
return Delivery {
sha: resolved,
hint_stale: stale,
};
}
Delivery {
sha: git::git_log_find_subject(repo_root, main_branch, &tag),
hint_stale: false,
}
}
/// Populate `task.delivered_in` at close time when it is still null
/// (SPEC ยง6; bl-87ea). Deferred-mode `bl review` never lands a local
/// squash, so it never writes the hint โ by the time `bl close` runs
/// after the forge merges, the field is still null. This caches the
/// `[id]`-tagged merge commit into the task file the close commit
/// archives, so a later `bl show` resolves via the fast hint path.
///
/// `manual` (`bl close --delivered <sha>`) wins unconditionally and
/// skips the scan โ the operator's explicit override for the case
/// where the forge produced a rebase-merge with several commits and
/// they want to point at a specific one. Otherwise this is a no-op
/// when the hint is already set (local-squash mode wrote it in
/// `review`, so that path stays byte-identical). When the hint is
/// null we reuse `resolve` โ the same tag-scan machinery, so this is
/// a strict generalization, not a new mechanism โ against the task's
/// effective `target_branch`. A miss warns and leaves the hint null:
/// the `[id]` tag in the merge subject is still ground truth, and the
/// half-push detector (which scans subjects, not this hint) is
/// unaffected.
///
/// Returns `true` iff `delivered_in` was set, so the close path knows
/// to persist the task to the state branch before archiving it (the
/// no-op local-squash path returns `false` and stays byte-identical).
pub fn populate_on_close(
repo_root: &Path,
target_branch: &str,
task: &mut Task,
manual: Option<String>,
) -> bool {
if let Some(sha) = manual {
task.delivered_in = Some(sha);
return true;
}
if task.delivered_in.is_some() {
return false;
}
if let Some(sha) = resolve(repo_root, target_branch, task).sha {
task.delivered_in = Some(sha);
true
} else {
eprintln!(
"warning: no [{id}] commit reachable on {target_branch}; closing \
without delivered_in (the [{id}] tag in the merge subject stays \
ground truth)",
id = task.id,
);
false
}
}
/// Human-friendly `"<short> <subject>"` for display in `bl show`.
pub fn describe(repo_root: &Path, sha: &str) -> String {
let short = git::git_short_sha(repo_root, sha).unwrap_or_else(|| sha.to_string());
match git::git_commit_subject(repo_root, sha) {
Some(subj) if !subj.is_empty() => format!("{short} {subj}"),
_ => short,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::task::{NewTaskOpts, Task};
use tempfile::TempDir;
fn empty_task() -> Task {
Task::new(
NewTaskOpts {
title: "t".into(),
..Default::default()
},
"bl-abcd".into(),
)
}
#[test]
fn resolve_returns_empty_when_not_a_git_repo() {
// Not a git repo: every git query fails, so resolve yields an
// empty, non-stale result regardless of the branch passed.
let dir = TempDir::new().unwrap();
let d = resolve(dir.path(), "main", &empty_task());
assert!(d.sha.is_none());
assert!(!d.hint_stale);
}
#[test]
fn populate_on_close_manual_override_wins_unconditionally() {
// `bl close --delivered <sha>` skips the scan and sets the
// hint even when one is already present (forge rebase-merge).
let dir = TempDir::new().unwrap();
let mut t = empty_task();
t.delivered_in = Some("oldsha".into());
let changed = populate_on_close(dir.path(), "main", &mut t, Some("forced".into()));
assert!(changed);
assert_eq!(t.delivered_in.as_deref(), Some("forced"));
}
#[test]
fn populate_on_close_is_noop_when_hint_already_set() {
// Local-squash mode wrote the hint in `review`; close must
// not touch it (no scan, byte-identical archived task).
let dir = TempDir::new().unwrap();
let mut t = empty_task();
t.delivered_in = Some("fromreview".into());
let changed = populate_on_close(dir.path(), "main", &mut t, None);
assert!(!changed);
assert_eq!(t.delivered_in.as_deref(), Some("fromreview"));
}
#[test]
fn populate_on_close_scan_miss_leaves_hint_null() {
// Null hint, no `[id]` commit reachable (not a git repo, so
// the tag scan finds nothing): warn and proceed with null.
let dir = TempDir::new().unwrap();
let mut t = empty_task();
let changed = populate_on_close(dir.path(), "main", &mut t, None);
assert!(!changed);
assert!(t.delivered_in.is_none());
}
#[test]
fn describe_falls_back_to_short_sha_when_no_subject() {
// A tempdir isn't a git repo, so both subject and short-sha
// lookups return None โ describe falls back to the raw sha.
let dir = TempDir::new().unwrap();
let out = describe(dir.path(), "deadbeef");
assert_eq!(out, "deadbeef");
}
}