Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! Step 6 of the indexing pipeline: rebase comment anchors onto the new
2//! revision (spec §4, §5).
3//!
4//! > This is the mechanism that makes stable change identity actually pay off
5//! > for reviewers, and it is the single most valuable piece of logic in the
6//! > product.
7//!
8//! The decision logic is `df_index::rebase_anchor`, which is pure and heavily
9//! tested. This module is the part that touches the world: read the two
10//! versions of each commented file out of the store, build the line mapping,
11//! and write the outcome back.
12//!
13//! Two properties it has to hold:
14//!
15//! * **Idempotent.** Re-running the indexer over the same revisions must not
16//! walk a comment further down the file each time. The anchor is always
17//! rebased *from the revision it is currently anchored to*, so a second run
18//! over the same pair is a no-op rather than a second shift.
19//! * **Never destructive.** A comment whose anchor cannot be resolved is marked
20//! `outdated` or `orphaned`. It is never deleted and its `anchor_context` —
21//! the line as it read when the comment was written — is never overwritten.
22
23use std::collections::HashMap;
24use std::path::Path;
25
26use anyhow::Result;
27use df_index::{rebase_anchor, AnchorOutcome};
28use df_store::{RepoId, RepoStore, RevId};
29use sqlx::PgPool;
30use uuid::Uuid;
31
32/// How many comments to rebase in one pass.
33///
34/// A change with thousands of inline comments is pathological, and reading two
35/// blobs per distinct file is the expensive part; the cap keeps one push from
36/// occupying the worker indefinitely.
37const MAX_COMMENTS: i64 = 2_000;
38
39#[derive(Debug, Default, PartialEq, Eq)]
40pub struct Summary {
41 pub moved: usize,
42 pub outdated: usize,
43 pub orphaned: usize,
44 pub unchanged: usize,
45}
46
47impl Summary {
48 pub fn touched(&self) -> usize {
49 self.moved + self.outdated + self.orphaned
50 }
51}
52
53struct Anchor {
54 id: Uuid,
55 path: String,
56 line: i32,
57 context: Option<String>,
58 state: String,
59}
60
61/// Re-anchor every inline comment on `change_uuid` from `from_rev` onto
62/// `to_rev`.
63pub async fn rebase(
64 db: &PgPool,
65 store: &dyn RepoStore,
66 repo: RepoId,
67 change_uuid: Uuid,
68 from_rev: &str,
69 to_rev: &str,
70 new_revision_id: Uuid,
71) -> Result<Summary> {
72 if from_rev == to_rev {
73 return Ok(Summary::default());
74 }
75
76 let rows: Vec<(Uuid, String, i32, Option<String>, String)> = sqlx::query_as(
77 "SELECT id, anchor_path, anchor_line, anchor_context, anchor_state::text
78 FROM comments
79 WHERE change_id_fk = $1
80 AND anchor_path IS NOT NULL
81 AND anchor_line IS NOT NULL
82 -- An orphaned anchor has nothing left to track. Re-examining it on
83 -- every push would be work with no possible outcome.
84 AND anchor_state <> 'orphaned'
85 ORDER BY id
86 LIMIT $2",
87 )
88 .bind(change_uuid)
89 .bind(MAX_COMMENTS)
90 .fetch_all(db)
91 .await?;
92
93 if rows.is_empty() {
94 return Ok(Summary::default());
95 }
96
97 let anchors: Vec<Anchor> = rows
98 .into_iter()
99 .map(|(id, path, line, context, state)| Anchor { id, path, line, context, state })
100 .collect();
101
102 // Group by file so each file's two blobs are read once, however many
103 // comments sit in it.
104 let mut by_path: HashMap<&str, Vec<&Anchor>> = HashMap::new();
105 for a in &anchors {
106 by_path.entry(a.path.as_str()).or_default().push(a);
107 }
108
109 let from = RevId::from_stored(from_rev.to_owned());
110 let to = RevId::from_stored(to_rev.to_owned());
111
112 let mut summary = Summary::default();
113
114 for (path, group) in by_path {
115 let p = Path::new(path);
116
117 let old_text = read_text(store, repo, &from, p).await;
118 let new_text = read_text(store, repo, &to, p).await;
119
120 // Gone from the new revision: every comment in it is orphaned and goes
121 // to the overview timeline (spec §5 step 5).
122 let Some(new_text) = new_text else {
123 for a in group {
124 set_state(db, a.id, "orphaned", None, new_revision_id).await?;
125 summary.orphaned += 1;
126 }
127 continue;
128 };
129
130 // Present in the new revision but unreadable in the old one — a file
131 // added by this rewrite, or one that used to be binary. There is no
132 // mapping to follow, so the content search inside `rebase_anchor` is the
133 // only thing that can save the anchor. An empty old side gives it
134 // exactly that.
135 let old_text = old_text.unwrap_or_default();
136
137 let mapping = df_index::line_map(&old_text, &new_text);
138
139 for a in group {
140 let outcome = rebase_anchor(a.line as u32, a.context.as_deref(), &mapping, true);
141
142 match outcome {
143 AnchorOutcome::Unchanged => {
144 // Still `current`, but the anchor now names the new
145 // revision — that is what makes the next rebase start from
146 // the right place, and what makes re-running idempotent.
147 set_state(db, a.id, "current", Some(a.line), new_revision_id).await?;
148 summary.unchanged += 1;
149 }
150 AnchorOutcome::Moved { new_line } => {
151 set_state(db, a.id, "current", Some(new_line as i32), new_revision_id).await?;
152 summary.moved += 1;
153 }
154 AnchorOutcome::Outdated => {
155 // The line number is deliberately left alone: it is where
156 // the comment was written, and moving it to a guess would
157 // make the retained context misleading.
158 set_state(db, a.id, "outdated", None, new_revision_id).await?;
159 if a.state != "outdated" {
160 summary.outdated += 1;
161 }
162 }
163 AnchorOutcome::Orphaned => {
164 set_state(db, a.id, "orphaned", None, new_revision_id).await?;
165 summary.orphaned += 1;
166 }
167 }
168 }
169 }
170
171 Ok(summary)
172}
173
174/// Read a file as text, or `None` if it is missing, binary, or too large.
175async fn read_text(
176 store: &dyn RepoStore,
177 repo: RepoId,
178 rev: &RevId,
179 path: &Path,
180) -> Option<String> {
181 match store.read_blob(repo, rev, path).await {
182 Ok(b) => b.text().map(str::to_owned),
183 Err(df_store::StoreError::NoSuchPath) | Err(df_store::StoreError::IsDirectory) => None,
184 Err(e) => {
185 // Anything else — too large, unreadable — is treated as "cannot
186 // follow this file", which produces an honest `orphaned` rather
187 // than a wrong line number.
188 tracing::debug!(?path, "reading a commented file failed: {e}");
189 None
190 }
191 }
192}
193
194/// Write one anchor's new state.
195///
196/// `anchor_context` is never touched: spec §5 step 4 says an outdated comment
197/// retains the original text for display, and that text is the only record of
198/// what the reviewer was looking at.
199async fn set_state(
200 db: &PgPool,
201 comment_id: Uuid,
202 state: &str,
203 line: Option<i32>,
204 revision_id: Uuid,
205) -> Result<()> {
206 sqlx::query(
207 "UPDATE comments
208 SET anchor_state = $2::anchor_state,
209 anchor_line = COALESCE($3, anchor_line),
210 anchor_revision = $4
211 WHERE id = $1",
212 )
213 .bind(comment_id)
214 .bind(state)
215 .bind(line)
216 .bind(revision_id)
217 .execute(db)
218 .await?;
219 Ok(())
220}

220 lines · Rust