Store checks-pass metadata as worktree-local Git notes keyed by tree ID. Show the marker in the TUI and plain status output, and expose it through the enrich shortcut group.
Unified · Split
| @@ -247,7 +247,12 @@ without trading responsiveness for metadata that is not visible. · 246 unmodified lines | ||
| 247 | 247 | Consequently, rewritten commits retain their metadata and commits sharing a |
| 248 | 248 | change ID share it as well. Malformed enrichments are ignored for display and |
| 249 | 249 | diagnosed, while mutation refuses to overwrite them. |
| 250 | −- Todo and note enrichments receive a leading `🚧` and `📝` respectively before | |
| 250 | +- Tree enrichments use the same human-readable Git config format in notes headed | |
| 251 | + at `refs/worktree/tix/enrich-tree`. They are keyed directly by tree object ID; | |
| 252 | + `[tree] checks-pass = true` therefore applies to every commit with that exact | |
| 253 | + tree and naturally disappears when a rewrite changes the tree. | |
| 254 | +- Todo, note, and checks-pass enrichments receive a leading `🚧`, `📝`, and `✔️` | |
| 255 | + respectively before | |
| 251 | 256 | the graph, with no gap between them or the following status field. The dedicated |
| 252 | 257 | field remains visible alongside selection, dirty-worktree, and conflict markers. |
| 253 | 258 | `tix show` emits the same field and aligns unmarked rows when any displayed |
| @@ -1014,8 +1019,10 @@ space first; changes blocks adapt within the remaining history width. · 760 unmodified lines | ||
| 1014 | 1019 | - The `n` in `enrich` toggles its shortcut group. On any commit eligible for rewording, |
| 1015 | 1020 | `n t` toggles `[commit] todo`, preserving a saved note, and `n o` opens |
| 1016 | 1021 | `[commit] note` in Git's editor as Markdown. Saving or removing a note preserves |
| 1017 | − the todo flag, and toggling todo preserves the note. `n g` edits the real Git | |
| 1018 | − note and remains available when the Tix-specific actions are not. The group is mutually | |
| 1022 | + the todo flag, and toggling todo preserves the note. `n c` toggles | |
| 1023 | + `[tree] checks-pass` for any selected commit, including immutable boundaries. | |
| 1024 | + `n g` edits the real Git note and remains available when the commit-specific | |
| 1025 | + Tix actions are not. The group is mutually | |
| 1019 | 1026 | exclusive with the view, commit, actions, and information groups and otherwise follows |
| 1020 | 1027 | their closing behavior. |
| 1021 | 1028 | |
| @@ -356,6 +356,7 @@ pub(crate) enum Action { · 355 unmodified lines | ||
| 356 | 356 | ToggleActions, |
| 357 | 357 | ToggleEnrich, |
| 358 | 358 | ToggleTodo, |
| 359 | + ToggleChecksPass, | |
| 359 | 360 | EditNote, |
| 360 | 361 | EditGitNote, |
| 361 | 362 | ToggleInformation, |
| @@ -435,6 +436,7 @@ pub(crate) enum Effect { · 73 unmodified lines | ||
| 435 | 436 | TimeTravel(ObjectId), |
| 436 | 437 | TogglePin(ObjectId), |
| 437 | 438 | ToggleTodo(ObjectId), |
| 439 | + ToggleChecksPass(ObjectId), | |
| 438 | 440 | EditNote(ObjectId), |
| 439 | 441 | EditGitNote(ObjectId), |
| 440 | 442 | VerifySignatures(Vec<ObjectId>), |
| @@ -475,6 +477,7 @@ pub(crate) struct App { · 34 unmodified lines | ||
| 475 | 477 | titles: Vec<u8>, |
| 476 | 478 | notes: HashMap<ObjectId, Vec<BString>>, |
| 477 | 479 | enrichments: HashMap<ObjectId, crate::enrich::Enrichment>, |
| 480 | + tree_enrichments: HashMap<ObjectId, crate::enrich::TreeEnrichment>, | |
| 478 | 481 | graph: Option<Graph>, |
| 479 | 482 | attributions: Vec<Attribution>, |
| 480 | 483 | #[cfg(test)] |
| @@ -575,6 +578,7 @@ impl App { · 94 unmodified lines | ||
| 575 | 578 | titles: Vec::new(), |
| 576 | 579 | notes: HashMap::new(), |
| 577 | 580 | enrichments: HashMap::new(), |
| 581 | + tree_enrichments: HashMap::new(), | |
| 578 | 582 | graph: None, |
| 579 | 583 | attributions: Vec::new(), |
| 580 | 584 | #[cfg(test)] |
| @@ -994,8 +998,23 @@ impl App { · 413 unmodified lines | ||
| 994 | 998 | .and_then(|enrichment| enrichment.note.as_ref().map(|note| note.as_bstr())) |
| 995 | 999 | } |
| 996 | 1000 | |
| 1001 | + pub(crate) fn tree_enrichment_loaded(&self, id: ObjectId) -> bool { | |
| 1002 | + self.tree_enrichments.contains_key(&id) | |
| 1003 | + } | |
| 1004 | + | |
| 1005 | + pub(crate) fn set_tree_enrichment(&mut self, id: ObjectId, enrichment: crate::enrich::TreeEnrichment) { | |
| 1006 | + self.tree_enrichments.insert(id, enrichment); | |
| 1007 | + } | |
| 1008 | + | |
| 1009 | + pub(crate) fn checks_pass(&self, id: ObjectId) -> bool { | |
| 1010 | + self.tree_enrichments | |
| 1011 | + .get(&id) | |
| 1012 | + .is_some_and(|enrichment| enrichment.checks_pass) | |
| 1013 | + } | |
| 1014 | + | |
| 997 | 1015 | pub(crate) fn clear_enrichments(&mut self) { |
| 998 | 1016 | self.enrichments.clear(); |
| 1017 | + self.tree_enrichments.clear(); | |
| 999 | 1018 | } |
| 1000 | 1019 | |
| 1001 | 1020 | pub(crate) fn render_lanes(&self, range: Range<usize>) -> RenderedLanes { |
| @@ -1068,7 +1087,11 @@ impl App { · 66 unmodified lines | ||
| 1068 | 1087 | } |
| 1069 | 1088 | if !matches!( |
| 1070 | 1089 | &action, |
| 1071 | − Action::ToggleEnrich | Action::ToggleTodo | Action::EditNote | Action::EditGitNote | |
| 1090 | + Action::ToggleEnrich | |
| 1091 | + | Action::ToggleTodo | |
| 1092 | + | Action::ToggleChecksPass | |
| 1093 | + | Action::EditNote | |
| 1094 | + | Action::EditGitNote | |
| 1072 | 1095 | ) { |
| 1073 | 1096 | self.enrich_expanded = false; |
| 1074 | 1097 | } |
| @@ -1443,6 +1466,11 @@ impl App { · 368 unmodified lines | ||
| 1443 | 1466 | self.rows[self.selected.expect("todo requires a selection")].id, |
| 1444 | 1467 | )]; |
| 1445 | 1468 | } |
| 1469 | + Action::ToggleChecksPass => { | |
| 1470 | + if let Some(id) = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id) { | |
| 1471 | + return vec![Effect::ToggleChecksPass(id)]; | |
| 1472 | + } | |
| 1473 | + } | |
| 1446 | 1474 | Action::EditNote if self.can_reword() => { |
| 1447 | 1475 | return vec![Effect::EditNote( |
| 1448 | 1476 | self.rows[self.selected.expect("note requires a selection")].id, |
| @@ -1783,7 +1811,7 @@ impl App { · 334 unmodified lines | ||
| 1783 | 1811 | self.pending_hidden_rows = None; |
| 1784 | 1812 | self.titles = Vec::new(); |
| 1785 | 1813 | self.notes.clear(); |
| 1786 | − self.enrichments.clear(); | |
| 1814 | + self.clear_enrichments(); | |
| 1787 | 1815 | self.graph = None; |
| 1788 | 1816 | self.attributions = Vec::new(); |
| 1789 | 1817 | #[cfg(test)] |
| @@ -4271,6 +4299,11 @@ mod tests { · 2481 unmodified lines | ||
| 4271 | 4299 | vec![Effect::EditGitNote(id(1))], |
| 4272 | 4300 | "Git notes are available on every selection" |
| 4273 | 4301 | ); |
| 4302 | + assert_eq!( | |
| 4303 | + app.update(Action::ToggleChecksPass), | |
| 4304 | + vec![Effect::ToggleChecksPass(id(1))], | |
| 4305 | + "tree enrichments are available on every selection" | |
| 4306 | + ); | |
| 4274 | 4307 | assert!(app.enrich_expanded, "the grouped actions keep enrich open"); |
| 4275 | 4308 | |
| 4276 | 4309 | app.update(Action::MoveDown); |
| @@ -4291,6 +4324,11 @@ mod tests { · 14 unmodified lines | ||
| 4291 | 4324 | vec![Effect::EditGitNote(id(1))], |
| 4292 | 4325 | "immutable boundaries still accept Git notes" |
| 4293 | 4326 | ); |
| 4327 | + assert_eq!( | |
| 4328 | + app.update(Action::ToggleChecksPass), | |
| 4329 | + vec![Effect::ToggleChecksPass(id(1))], | |
| 4330 | + "immutable boundaries still accept tree enrichments" | |
| 4331 | + ); | |
| 4294 | 4332 | } |
| 4295 | 4333 | |
| 4296 | 4334 | #[test] |
| @@ -311,6 +311,20 @@ fn write_history( · 310 unmodified lines | ||
| 311 | 311 | } |
| 312 | 312 | } |
| 313 | 313 | |
| 314 | + let mut checks_pass_ids = HashSet::new(); | |
| 315 | + let mut tree_enrichments = crate::enrich::open_tree(repository)?; | |
| 316 | + for row in &app.rows { | |
| 317 | + let loaded = crate::enrich::tree_id(repository, row.id) | |
| 318 | + .and_then(|tree_id| crate::enrich::load_tree(&mut tree_enrichments, tree_id)); | |
| 319 | + match loaded { | |
| 320 | + Ok(enrichment) if enrichment.checks_pass => { | |
| 321 | + checks_pass_ids.insert(row.id); | |
| 322 | + } | |
| 323 | + Ok(_) => {} | |
| 324 | + Err(err) => tracing::warn!(commit_id = %row.id, error = %err, "ignored malformed tix tree enrichment"), | |
| 325 | + } | |
| 326 | + } | |
| 327 | + | |
| 314 | 328 | let change_ids = crate::change_id::abbreviations(repository, app.rows.iter().map(|row| row.id), 7)?; |
| 315 | 329 | |
| 316 | 330 | let mailmap = repository.open_mailmap(); |
| @@ -322,6 +336,7 @@ fn write_history( · 5 unmodified lines | ||
| 322 | 336 | Line::raw(crate::enrich::marker( |
| 323 | 337 | todo_ids.contains(&row.id), |
| 324 | 338 | enrichment_note_ids.contains(&row.id), |
| 339 | + checks_pass_ids.contains(&row.id), | |
| 325 | 340 | )) |
| 326 | 341 | .width() |
| 327 | 342 | }) |
| @@ -337,8 +352,11 @@ fn write_history( · 9 unmodified lines | ||
| 337 | 352 | note_ids.contains(&row.id), |
| 338 | 353 | change_ids.values.get(&row.id).copied(), |
| 339 | 354 | ); |
| 340 | − let enrichment_marker = | |
| 341 | − crate::enrich::marker(todo_ids.contains(&row.id), enrichment_note_ids.contains(&row.id)); | |
| 355 | + let enrichment_marker = crate::enrich::marker( | |
| 356 | + todo_ids.contains(&row.id), | |
| 357 | + enrichment_note_ids.contains(&row.id), | |
| 358 | + checks_pass_ids.contains(&row.id), | |
| 359 | + ); | |
| 342 | 360 | let ambiguity_marker = if change_ids.ambiguous.contains(&row.id) { |
| 343 | 361 | "💥" |
| 344 | 362 | } else { |
| @@ -1150,6 +1168,7 @@ mod tests { · 805 unmodified lines | ||
| 1150 | 1168 | create_pins(&repository, &[OsString::from(orphan.to_string())])?; |
| 1151 | 1169 | let head_change_id = crate::change_id::for_commit(&repository, head)?; |
| 1152 | 1170 | assert!(crate::enrich::toggle(&repository, head)?.todo); |
| 1171 | + assert!(crate::enrich::toggle_checks_pass(&repository, head)?.checks_pass); | |
| 1153 | 1172 | |
| 1154 | 1173 | let mut output = Vec::new(); |
| 1155 | 1174 | write_history(&repository, &[], &[OsString::from("v1")], &mut output)?; |
| @@ -1202,8 +1221,8 @@ mod tests { · 46 unmodified lines | ||
| 1202 | 1221 | } |
| 1203 | 1222 | assert!(output.contains('●'), "history graph lanes are rendered"); |
| 1204 | 1223 | assert!( |
| 1205 | − output.lines().any(|line| line.starts_with("🚧💥├")), | |
| 1206 | − "todos directly lead their rows: {output:?}" | |
| 1224 | + output.lines().any(|line| line.starts_with("🚧✔️💥├")), | |
| 1225 | + "commit and tree enrichments directly lead their rows: {output:?}" | |
| 1207 | 1226 | ); |
| 1208 | 1227 | assert!(output.contains("📌"), "applicable pins are decorated and traversed"); |
| 1209 | 1228 | assert!( |
| @@ -8,6 +8,7 @@ use gix::{ · 7 unmodified lines | ||
| 8 | 8 | }; |
| 9 | 9 | |
| 10 | 10 | pub(crate) const REF_NAME: &str = "refs/worktree/tix/enrich"; |
| 11 | +pub(crate) const TREE_REF_NAME: &str = "refs/worktree/tix/enrich-tree"; | |
| 11 | 12 | |
| 12 | 13 | #[derive(Clone, Debug, Default, Eq, PartialEq)] |
| 13 | 14 | pub(crate) struct Enrichment { |
| @@ -15,30 +16,47 @@ pub(crate) struct Enrichment { · 1 unmodified line | ||
| 15 | 16 | pub note: Option<BString>, |
| 16 | 17 | } |
| 17 | 18 | |
| 19 | +#[derive(Clone, Debug, Default, Eq, PartialEq)] | |
| 20 | +pub(crate) struct TreeEnrichment { | |
| 21 | + pub checks_pass: bool, | |
| 22 | +} | |
| 23 | + | |
| 18 | 24 | #[derive(Clone, Debug, Default, Eq, PartialEq)] |
| 19 | 25 | pub(crate) struct Headers { |
| 20 | 26 | pub todo: bool, |
| 21 | 27 | pub message: Option<BString>, |
| 22 | 28 | } |
| 23 | 29 | |
| 24 | −pub(crate) fn marker(todo: bool, note: bool) -> &'static str { | |
| 25 | − match (todo, note) { | |
| 26 | − (true, true) => "🚧📝", | |
| 27 | − (true, false) => "🚧", | |
| 28 | − (false, true) => "📝", | |
| 29 | − (false, false) => "", | |
| 30 | +pub(crate) fn marker(todo: bool, note: bool, checks_pass: bool) -> &'static str { | |
| 31 | + match (todo, note, checks_pass) { | |
| 32 | + (true, true, true) => "🚧📝✔️", | |
| 33 | + (true, true, false) => "🚧📝", | |
| 34 | + (true, false, true) => "🚧✔️", | |
| 35 | + (true, false, false) => "🚧", | |
| 36 | + (false, true, true) => "📝✔️", | |
| 37 | + (false, true, false) => "📝", | |
| 38 | + (false, false, true) => "✔️", | |
| 39 | + (false, false, false) => "", | |
| 30 | 40 | } |
| 31 | 41 | } |
| 32 | 42 | |
| 33 | 43 | pub(crate) fn open(repo: &gix::Repository) -> Result<gix::note::Platform<'_>> { |
| 44 | + open_at(repo, REF_NAME) | |
| 45 | +} | |
| 46 | + | |
| 47 | +pub(crate) fn open_tree(repo: &gix::Repository) -> Result<gix::note::Platform<'_>> { | |
| 48 | + open_at(repo, TREE_REF_NAME) | |
| 49 | +} | |
| 50 | + | |
| 51 | +fn open_at<'repo>(repo: &'repo gix::Repository, reference: &str) -> Result<gix::note::Platform<'repo>> { | |
| 34 | 52 | repo.notes() |
| 35 | 53 | .context("could not open tix enrichments")? |
| 36 | − .with_refs([REF_NAME]) | |
| 54 | + .with_refs([reference]) | |
| 37 | 55 | .context("could not select the tix enrich reference") |
| 38 | 56 | } |
| 39 | 57 | |
| 40 | 58 | pub(crate) fn load(notes: &mut gix::note::Platform, change_id: ChangeId) -> Result<Enrichment> { |
| 41 | − let config = load_config(notes, change_id)?; | |
| 59 | + let config = load_config(notes, ObjectId::from(change_id))?; | |
| 42 | 60 | let Some(config) = config else { |
| 43 | 61 | return Ok(Enrichment::default()); |
| 44 | 62 | }; |
| @@ -51,10 +69,20 @@ pub(crate) fn load(notes: &mut gix::note::Platform, change_id: ChangeId) -> Resu · 6 unmodified lines | ||
| 51 | 69 | }) |
| 52 | 70 | } |
| 53 | 71 | |
| 54 | −fn load_config(notes: &mut gix::note::Platform, change_id: ChangeId) -> Result<Option<File>> { | |
| 55 | − let found = notes | |
| 56 | − .get(ObjectId::from(change_id)) | |
| 57 | − .context("could not load the tix enrichment")?; | |
| 72 | +pub(crate) fn load_tree(notes: &mut gix::note::Platform, tree_id: ObjectId) -> Result<TreeEnrichment> { | |
| 73 | + let Some(config) = load_config(notes, tree_id)? else { | |
| 74 | + return Ok(TreeEnrichment::default()); | |
| 75 | + }; | |
| 76 | + Ok(TreeEnrichment { | |
| 77 | + checks_pass: config | |
| 78 | + .boolean("tree.checks-pass") | |
| 79 | + .context("tree.checks-pass is not a boolean")? | |
| 80 | + .unwrap_or(false), | |
| 81 | + }) | |
| 82 | +} | |
| 83 | + | |
| 84 | +fn load_config(notes: &mut gix::note::Platform, object_id: ObjectId) -> Result<Option<File>> { | |
| 85 | + let found = notes.get(object_id).context("could not load the tix enrichment")?; | |
| 58 | 86 | found |
| 59 | 87 | .first() |
| 60 | 88 | .map(|note| { |
| @@ -63,6 +91,14 @@ fn load_config(notes: &mut gix::note::Platform, change_id: ChangeId) -> Result<O · 2 unmodified lines | ||
| 63 | 91 | .transpose() |
| 64 | 92 | } |
| 65 | 93 | |
| 94 | +pub(crate) fn tree_id(repo: &gix::Repository, commit_id: ObjectId) -> Result<ObjectId> { | |
| 95 | + repo.find_commit(commit_id) | |
| 96 | + .context("could not find the enriched commit")? | |
| 97 | + .tree_id() | |
| 98 | + .context("could not read the enriched commit tree") | |
| 99 | + .map(gix::Id::detach) | |
| 100 | +} | |
| 101 | + | |
| 66 | 102 | pub(crate) fn toggle(repo: &gix::Repository, commit_id: ObjectId) -> Result<Enrichment> { |
| 67 | 103 | update(repo, commit_id, |config| { |
| 68 | 104 | let enabled = !config |
| @@ -95,6 +131,22 @@ pub(crate) fn set_note(repo: &gix::Repository, commit_id: ObjectId, note: Option · 26 unmodified lines | ||
| 95 | 131 | }) |
| 96 | 132 | } |
| 97 | 133 | |
| 134 | +pub(crate) fn toggle_checks_pass(repo: &gix::Repository, commit_id: ObjectId) -> Result<TreeEnrichment> { | |
| 135 | + let tree_id = tree_id(repo, commit_id)?; | |
| 136 | + update_tree(repo, tree_id, |config| { | |
| 137 | + let enabled = !config | |
| 138 | + .boolean("tree.checks-pass") | |
| 139 | + .context("tree.checks-pass is not a boolean")? | |
| 140 | + .unwrap_or(false); | |
| 141 | + config | |
| 142 | + .section_mut_or_create_new("tree", None) | |
| 143 | + .context("could not create the tree enrichment section")? | |
| 144 | + .set("checks-pass", if enabled { "true" } else { "false" }) | |
| 145 | + .context("could not update tree.checks-pass")?; | |
| 146 | + Ok(()) | |
| 147 | + }) | |
| 148 | +} | |
| 149 | + | |
| 98 | 150 | pub(crate) fn apply_headers( |
| 99 | 151 | repo: &gix::Repository, |
| 100 | 152 | commit_id: ObjectId, |
| @@ -155,7 +207,7 @@ fn update( · 54 unmodified lines | ||
| 155 | 207 | ) -> Result<Enrichment> { |
| 156 | 208 | let change_id = crate::change_id::for_commit(repo, commit_id)?; |
| 157 | 209 | let mut notes = open(repo)?; |
| 158 | − let mut config = load_config(&mut notes, change_id)?.unwrap_or_default(); | |
| 210 | + let mut config = load_config(&mut notes, ObjectId::from(change_id))?.unwrap_or_default(); | |
| 159 | 211 | edit(&mut config)?; |
| 160 | 212 | let reference: FullName = REF_NAME.try_into().expect("the tix enrich reference is valid"); |
| 161 | 213 | notes |
| @@ -164,6 +216,23 @@ fn update( · 2 unmodified lines | ||
| 164 | 216 | load(&mut notes, change_id) |
| 165 | 217 | } |
| 166 | 218 | |
| 219 | +fn update_tree( | |
| 220 | + repo: &gix::Repository, | |
| 221 | + tree_id: ObjectId, | |
| 222 | + edit: impl FnOnce(&mut File) -> Result<()>, | |
| 223 | +) -> Result<TreeEnrichment> { | |
| 224 | + let mut notes = open_tree(repo)?; | |
| 225 | + let mut config = load_config(&mut notes, tree_id)?.unwrap_or_default(); | |
| 226 | + edit(&mut config)?; | |
| 227 | + let reference: FullName = TREE_REF_NAME | |
| 228 | + .try_into() | |
| 229 | + .expect("the tix tree enrich reference is valid"); | |
| 230 | + notes | |
| 231 | + .add_to_ref(reference.as_ref(), tree_id, config.to_bstring()) | |
| 232 | + .context("could not write the tix tree enrichment")?; | |
| 233 | + load_tree(&mut notes, tree_id) | |
| 234 | +} | |
| 235 | + | |
| 167 | 236 | #[cfg(test)] |
| 168 | 237 | mod tests { |
| 169 | 238 | use std::process::Command; |
| @@ -224,6 +293,46 @@ mod tests { · 54 unmodified lines | ||
| 224 | 293 | Ok(()) |
| 225 | 294 | } |
| 226 | 295 | |
| 296 | + #[test] | |
| 297 | + fn checks_pass_follows_only_the_exact_tree_and_preserves_other_fields() -> gix_testtools::Result { | |
| 298 | + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; | |
| 299 | + let repo = crate::test_repository::open_with( | |
| 300 | + fixture.path(), | |
| 301 | + ["user.name=checks author", "user.email=checks@example.com"], | |
| 302 | + )?; | |
| 303 | + let original = repo.head_id()?.detach(); | |
| 304 | + let tree = tree_id(&repo, original)?; | |
| 305 | + let reference: FullName = TREE_REF_NAME.try_into()?; | |
| 306 | + repo.notes()?.add_to_ref( | |
| 307 | + reference.as_ref(), | |
| 308 | + tree, | |
| 309 | + b"[tree]\n\tchecks-pass = false\n\towner = me\n", | |
| 310 | + )?; | |
| 311 | + | |
| 312 | + assert!(toggle_checks_pass(&repo, original)?.checks_pass); | |
| 313 | + let mut notes = open_tree(&repo)?; | |
| 314 | + let note = notes.get(tree)?.into_iter().next().expect("the tree enrichment exists"); | |
| 315 | + let config = File::try_from(note.blob.data.as_bstr())?; | |
| 316 | + assert_eq!( | |
| 317 | + config.string("tree.owner").as_ref().map(|value| value.as_bstr()), | |
| 318 | + Some(b"me".as_bstr()) | |
| 319 | + ); | |
| 320 | + | |
| 321 | + let mut rewritten = repo.find_commit(original)?.decode()?.into_owned()?; | |
| 322 | + rewritten.message = "same tree".into(); | |
| 323 | + let rewritten = repo.write_object(&rewritten)?.detach(); | |
| 324 | + assert!( | |
| 325 | + load_tree(&mut open_tree(&repo)?, tree_id(&repo, rewritten)?)?.checks_pass, | |
| 326 | + "a message-only rewrite retains the tree marker" | |
| 327 | + ); | |
| 328 | + let changed_tree = tree_id(&repo, repo.rev_parse_single("HEAD~1")?.detach())?; | |
| 329 | + assert!( | |
| 330 | + !load_tree(&mut open_tree(&repo)?, changed_tree)?.checks_pass, | |
| 331 | + "a different tree has no marker" | |
| 332 | + ); | |
| 333 | + Ok(()) | |
| 334 | + } | |
| 335 | + | |
| 227 | 336 | #[test] |
| 228 | 337 | fn notes_and_todos_are_independent() -> gix_testtools::Result { |
| 229 | 338 | let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; |
| @@ -316,6 +425,13 @@ mod tests { · 86 unmodified lines | ||
| 316 | 425 | toggle(&repo, id).is_err(), |
| 317 | 426 | "mutation does not replace malformed enrichments" |
| 318 | 427 | ); |
| 428 | + let tree = tree_id(&repo, id)?; | |
| 429 | + let reference: FullName = TREE_REF_NAME.try_into()?; | |
| 430 | + repo.notes()?.add_to_ref(reference.as_ref(), tree, b"[tree")?; | |
| 431 | + assert!( | |
| 432 | + toggle_checks_pass(&repo, id).is_err(), | |
| 433 | + "tree mutation does not replace malformed enrichments" | |
| 434 | + ); | |
| 319 | 435 | Ok(()) |
| 320 | 436 | } |
| 321 | 437 | |
| @@ -347,6 +463,12 @@ mod tests { · 25 unmodified lines | ||
| 347 | 463 | load(&mut open(&linked)?, change_id)?.todo, |
| 348 | 464 | "linked enrichments survive main-worktree changes" |
| 349 | 465 | ); |
| 466 | + let tree = tree_id(&main, id)?; | |
| 467 | + assert!(toggle_checks_pass(&main, id)?.checks_pass); | |
| 468 | + assert!( | |
| 469 | + !load_tree(&mut open_tree(&linked)?, tree)?.checks_pass, | |
| 470 | + "tree enrichments are also worktree-local" | |
| 471 | + ); | |
| 350 | 472 | Ok(()) |
| 351 | 473 | } |
| 352 | 474 | } |
| @@ -2702,6 +2702,26 @@ fn event_loop( · 2701 unmodified lines | ||
| 2702 | 2702 | Err(err) => app.leave_error(format!("todo: {err:#}")), |
| 2703 | 2703 | } |
| 2704 | 2704 | } |
| 2705 | + Effect::ToggleChecksPass(id) => { | |
| 2706 | + fill_repository.retain = false; | |
| 2707 | + fill_repository.retained = None; | |
| 2708 | + let result = open_repository(&repository_path, repository_is_bare, false) | |
| 2709 | + .context("could not open repository to update the tree enrichment") | |
| 2710 | + .and_then(|repo| enrich::toggle_checks_pass(&repo, id)); | |
| 2711 | + match result { | |
| 2712 | + Ok(enrichment) => { | |
| 2713 | + let enabled = enrichment.checks_pass; | |
| 2714 | + app.clear_enrichments(); | |
| 2715 | + app.set_tree_enrichment(id, enrichment); | |
| 2716 | + app.leave_success(if enabled { | |
| 2717 | + "marked tree checks-pass" | |
| 2718 | + } else { | |
| 2719 | + "cleared tree checks-pass" | |
| 2720 | + }); | |
| 2721 | + } | |
| 2722 | + Err(err) => app.leave_error(format!("checks-pass: {err:#}")), | |
| 2723 | + } | |
| 2724 | + } | |
| 2705 | 2725 | Effect::EditNote(id) => { |
| 2706 | 2726 | fill_repository.retain = false; |
| 2707 | 2727 | fill_repository.retained = None; |
| @@ -3119,6 +3139,11 @@ fn draw( · 411 unmodified lines | ||
| 3119 | 3139 | .map(|row| row.id) |
| 3120 | 3140 | .filter(|id| repository_fill_allowed && !app.enrichment_loaded(*id)) |
| 3121 | 3141 | .collect(); |
| 3142 | + let tree_enrichments_to_load: Vec<_> = app.rows[start..end] | |
| 3143 | + .iter() | |
| 3144 | + .map(|row| row.id) | |
| 3145 | + .filter(|id| repository_fill_allowed && !app.tree_enrichment_loaded(*id)) | |
| 3146 | + .collect(); | |
| 3122 | 3147 | let changes_visible = app.changes_visible(); |
| 3123 | 3148 | let selected_id = app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id); |
| 3124 | 3149 | app.selection_relation = selection_cache |
| @@ -3193,6 +3218,7 @@ fn draw( · 68 unmodified lines | ||
| 3193 | 3218 | } |
| 3194 | 3219 | if !notes_to_load.is_empty() |
| 3195 | 3220 | || !enrichments_to_load.is_empty() |
| 3221 | + || !tree_enrichments_to_load.is_empty() | |
| 3196 | 3222 | || app.rows[start..end].iter().any(|row| !row.metadata_loaded) |
| 3197 | 3223 | || message_to_load.is_some() |
| 3198 | 3224 | || tree_changes_to_load.is_some() |
| @@ -3236,6 +3262,19 @@ fn draw( · 37 unmodified lines | ||
| 3236 | 3262 | } |
| 3237 | 3263 | } |
| 3238 | 3264 | } |
| 3265 | + if !tree_enrichments_to_load.is_empty() { | |
| 3266 | + let mut notes = enrich::open_tree(repository)?; | |
| 3267 | + for id in tree_enrichments_to_load { | |
| 3268 | + let loaded = enrich::tree_id(repository, id).and_then(|tree_id| enrich::load_tree(&mut notes, tree_id)); | |
| 3269 | + match loaded { | |
| 3270 | + Ok(enrichment) => app.set_tree_enrichment(id, enrichment), | |
| 3271 | + Err(err) => { | |
| 3272 | + tracing::warn!(commit_id = %id, error = %err, "ignored malformed tix tree enrichment"); | |
| 3273 | + app.set_tree_enrichment(id, enrich::TreeEnrichment::default()); | |
| 3274 | + } | |
| 3275 | + } | |
| 3276 | + } | |
| 3277 | + } | |
| 3239 | 3278 | for index in start..end { |
| 3240 | 3279 | if app.rows[index].metadata_loaded { |
| 3241 | 3280 | continue; |
| @@ -5085,6 +5124,7 @@ fn action_with_shortcut_groups( · 1843 unmodified lines | ||
| 5085 | 5124 | KeyCode::Char('s') if commit_expanded => Some(Action::Spill), |
| 5086 | 5125 | KeyCode::Char('d') if commit_expanded => Some(Action::Forget), |
| 5087 | 5126 | KeyCode::Char('i') if commit_expanded => Some(Action::TogglePin), |
| 5127 | + KeyCode::Char('c') if enrich_expanded => Some(Action::ToggleChecksPass), | |
| 5088 | 5128 | KeyCode::Char('t') if enrich_expanded => Some(Action::ToggleTodo), |
| 5089 | 5129 | KeyCode::Char('o') if enrich_expanded => Some(Action::EditNote), |
| 5090 | 5130 | KeyCode::Char('@') => Some(Action::TimeTravel), |
| @@ -6179,6 +6219,17 @@ mod tests { · 1088 unmodified lines | ||
| 6179 | 6219 | action(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)), |
| 6180 | 6220 | Some(Action::ToggleEnrich) |
| 6181 | 6221 | ); |
| 6222 | + assert_eq!( | |
| 6223 | + action_with_shortcut_groups( | |
| 6224 | + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), | |
| 6225 | + false, | |
| 6226 | + false, | |
| 6227 | + false, | |
| 6228 | + true, | |
| 6229 | + false | |
| 6230 | + ), | |
| 6231 | + Some(Action::ToggleChecksPass) | |
| 6232 | + ); | |
| 6182 | 6233 | assert_eq!( |
| 6183 | 6234 | action_with_shortcut_groups( |
| 6184 | 6235 | KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), |
| @@ -479,7 +479,14 @@ pub(crate) fn draw_with_worktree( · 478 unmodified lines | ||
| 479 | 479 | let lanes = app.render_lanes(start..render_end); |
| 480 | 480 | let enrichment_gutter = visible_rows |
| 481 | 481 | .iter() |
| 482 | − .map(|row| Line::raw(crate::enrich::marker(app.todo(row.id), app.note(row.id).is_some())).width()) | |
| 482 | + .map(|row| { | |
| 483 | + Line::raw(crate::enrich::marker( | |
| 484 | + app.todo(row.id), | |
| 485 | + app.note(row.id).is_some(), | |
| 486 | + app.checks_pass(row.id), | |
| 487 | + )) | |
| 488 | + .width() | |
| 489 | + }) | |
| 483 | 490 | .max() |
| 484 | 491 | .unwrap_or_default() as u16; |
| 485 | 492 | let change_id_gutter = if visible_rows.iter().any(|row| app.has_duplicate_change_id(row.id)) { |
| @@ -692,6 +699,7 @@ pub(crate) fn draw_with_worktree( · 206 unmodified lines | ||
| 692 | 699 | let enrichment_marker = crate::enrich::marker( |
| 693 | 700 | app.todo(visible_rows[index].id), |
| 694 | 701 | app.note(visible_rows[index].id).is_some(), |
| 702 | + app.checks_pass(visible_rows[index].id), | |
| 695 | 703 | ); |
| 696 | 704 | if !enrichment_marker.is_empty() { |
| 697 | 705 | frame.render_widget( |
| @@ -1154,6 +1162,8 @@ pub(crate) fn draw_with_worktree( · 456 unmodified lines | ||
| 1154 | 1162 | ordered.extend(shortcut("note", 'o', app.note(row.id).is_some())); |
| 1155 | 1163 | ordered.push(Span::raw(" · ")); |
| 1156 | 1164 | } |
| 1165 | + ordered.extend(shortcut("checks-pass", 'c', app.checks_pass(row.id))); | |
| 1166 | + ordered.push(Span::raw(" · ")); | |
| 1157 | 1167 | ordered.extend(shortcut("git note", 'g', !app.notes(row.id).is_empty())); |
| 1158 | 1168 | } else { |
| 1159 | 1169 | ordered.push(Span::raw("no actions")); |
| @@ -2656,14 +2666,16 @@ mod tests { · 1496 unmodified lines | ||
| 2656 | 2666 | note: Some("follow-up *title*\n\nwhy it matters\n".into()), |
| 2657 | 2667 | }, |
| 2658 | 2668 | ); |
| 2669 | + app.set_tree_enrichment(id, crate::enrich::TreeEnrichment { checks_pass: true }); | |
| 2659 | 2670 | let mut terminal = Terminal::new(TestBackend::new(80, 2))?; |
| 2660 | 2671 | terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; |
| 2661 | 2672 | |
| 2662 | 2673 | let row = terminal.backend().buffer(); |
| 2663 | 2674 | assert_eq!(row[(0, 0)].symbol(), "🚧", "todo leads the row"); |
| 2664 | 2675 | assert_eq!(row[(2, 0)].symbol(), "📝", "note directly follows todo"); |
| 2665 | − assert_eq!(row[(4, 0)].symbol(), ">", "selection directly follows enrichments"); | |
| 2666 | − assert_eq!(row[(6, 0)].symbol(), "●", "the graph remains separate"); | |
| 2676 | + assert_eq!(row[(4, 0)].symbol(), "✔️", "tree status follows commit enrichments"); | |
| 2677 | + assert_eq!(row[(6, 0)].symbol(), ">", "selection directly follows enrichments"); | |
| 2678 | + assert_eq!(row[(8, 0)].symbol(), "●", "the graph remains separate"); | |
| 2667 | 2679 | assert!( |
| 2668 | 2680 | rendered_line(&terminal, 0).contains("follow-up title"), |
| 2669 | 2681 | "the selected todo displays its note title" |
| @@ -2680,7 +2692,7 @@ mod tests { · 10 unmodified lines | ||
| 2680 | 2692 | app.enrich_expanded = true; |
| 2681 | 2693 | terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; |
| 2682 | 2694 | assert!( |
| 2683 | − rendered_line(&terminal, 1).contains("enrich (todo · note · git note)"), | |
| 2695 | + rendered_line(&terminal, 1).contains("enrich (todo · note · checks-pass · git note)"), | |
| 2684 | 2696 | "the enrich group advertises all note actions" |
| 2685 | 2697 | ); |
| 2686 | 2698 | app.enrich_expanded = false; |
| @@ -2705,8 +2717,9 @@ mod tests { · 18 unmodified lines | ||
| 2705 | 2717 | "a note replaces the selected title independently of todo" |
| 2706 | 2718 | ); |
| 2707 | 2719 | assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), "📝"); |
| 2708 | − assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), ">"); | |
| 2709 | − assert_eq!(terminal.backend().buffer()[(4, 0)].symbol(), "●"); | |
| 2720 | + assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "✔️"); | |
| 2721 | + assert_eq!(terminal.backend().buffer()[(4, 0)].symbol(), ">"); | |
| 2722 | + assert_eq!(terminal.backend().buffer()[(6, 0)].symbol(), "●"); | |
| 2710 | 2723 | Ok(()) |
| 2711 | 2724 | } |
| 2712 | 2725 | |