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 | Consequently, rewritten commits retain their metadata and commits sharing a | 247 | Consequently, rewritten commits retain their metadata and commits sharing a |
| 248 | change ID share it as well. Malformed enrichments are ignored for display and | 248 | change ID share it as well. Malformed enrichments are ignored for display and |
| 249 | diagnosed, while mutation refuses to overwrite them. | 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 | the graph, with no gap between them or the following status field. The dedicated | 256 | the graph, with no gap between them or the following status field. The dedicated |
| 252 | field remains visible alongside selection, dirty-worktree, and conflict markers. | 257 | field remains visible alongside selection, dirty-worktree, and conflict markers. |
| 253 | `tix show` emits the same field and aligns unmarked rows when any displayed | 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 | - The `n` in `enrich` toggles its shortcut group. On any commit eligible for rewording, | 1019 | - The `n` in `enrich` toggles its shortcut group. On any commit eligible for rewording, |
| 1015 | `n t` toggles `[commit] todo`, preserving a saved note, and `n o` opens | 1020 | `n t` toggles `[commit] todo`, preserving a saved note, and `n o` opens |
| 1016 | `[commit] note` in Git's editor as Markdown. Saving or removing a note preserves | 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 | 1022 | the todo flag, and toggling todo preserves the note. `n c` toggles |
| 1018 | note and remains available when the Tix-specific actions are not. The group is mutually | 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 | exclusive with the view, commit, actions, and information groups and otherwise follows | 1026 | exclusive with the view, commit, actions, and information groups and otherwise follows |
| 1020 | their closing behavior. | 1027 | their closing behavior. |
| 1021 | 1028 | ||
| @@ -356,6 +356,7 @@ pub(crate) enum Action { · 355 unmodified lines | |||
| 356 | ToggleActions, | 356 | ToggleActions, |
| 357 | ToggleEnrich, | 357 | ToggleEnrich, |
| 358 | ToggleTodo, | 358 | ToggleTodo, |
| 359 | ToggleChecksPass, | ||
| 359 | EditNote, | 360 | EditNote, |
| 360 | EditGitNote, | 361 | EditGitNote, |
| 361 | ToggleInformation, | 362 | ToggleInformation, |
| @@ -435,6 +436,7 @@ pub(crate) enum Effect { · 73 unmodified lines | |||
| 435 | TimeTravel(ObjectId), | 436 | TimeTravel(ObjectId), |
| 436 | TogglePin(ObjectId), | 437 | TogglePin(ObjectId), |
| 437 | ToggleTodo(ObjectId), | 438 | ToggleTodo(ObjectId), |
| 439 | ToggleChecksPass(ObjectId), | ||
| 438 | EditNote(ObjectId), | 440 | EditNote(ObjectId), |
| 439 | EditGitNote(ObjectId), | 441 | EditGitNote(ObjectId), |
| 440 | VerifySignatures(Vec<ObjectId>), | 442 | VerifySignatures(Vec<ObjectId>), |
| @@ -475,6 +477,7 @@ pub(crate) struct App { · 34 unmodified lines | |||
| 475 | titles: Vec<u8>, | 477 | titles: Vec<u8>, |
| 476 | notes: HashMap<ObjectId, Vec<BString>>, | 478 | notes: HashMap<ObjectId, Vec<BString>>, |
| 477 | enrichments: HashMap<ObjectId, crate::enrich::Enrichment>, | 479 | enrichments: HashMap<ObjectId, crate::enrich::Enrichment>, |
| 480 | tree_enrichments: HashMap<ObjectId, crate::enrich::TreeEnrichment>, | ||
| 478 | graph: Option<Graph>, | 481 | graph: Option<Graph>, |
| 479 | attributions: Vec<Attribution>, | 482 | attributions: Vec<Attribution>, |
| 480 | #[cfg(test)] | 483 | #[cfg(test)] |
| @@ -575,6 +578,7 @@ impl App { · 94 unmodified lines | |||
| 575 | titles: Vec::new(), | 578 | titles: Vec::new(), |
| 576 | notes: HashMap::new(), | 579 | notes: HashMap::new(), |
| 577 | enrichments: HashMap::new(), | 580 | enrichments: HashMap::new(), |
| 581 | tree_enrichments: HashMap::new(), | ||
| 578 | graph: None, | 582 | graph: None, |
| 579 | attributions: Vec::new(), | 583 | attributions: Vec::new(), |
| 580 | #[cfg(test)] | 584 | #[cfg(test)] |
| @@ -994,8 +998,23 @@ impl App { · 413 unmodified lines | |||
| 994 | .and_then(|enrichment| enrichment.note.as_ref().map(|note| note.as_bstr())) | 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 | pub(crate) fn clear_enrichments(&mut self) { | 1015 | pub(crate) fn clear_enrichments(&mut self) { |
| 998 | self.enrichments.clear(); | 1016 | self.enrichments.clear(); |
| 1017 | self.tree_enrichments.clear(); | ||
| 999 | } | 1018 | } |
| 1000 | 1019 | ||
| 1001 | pub(crate) fn render_lanes(&self, range: Range<usize>) -> RenderedLanes { | 1020 | pub(crate) fn render_lanes(&self, range: Range<usize>) -> RenderedLanes { |
| @@ -1068,7 +1087,11 @@ impl App { · 66 unmodified lines | |||
| 1068 | } | 1087 | } |
| 1069 | if !matches!( | 1088 | if !matches!( |
| 1070 | &action, | 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 | self.enrich_expanded = false; | 1096 | self.enrich_expanded = false; |
| 1074 | } | 1097 | } |
| @@ -1443,6 +1466,11 @@ impl App { · 368 unmodified lines | |||
| 1443 | self.rows[self.selected.expect("todo requires a selection")].id, | 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 | Action::EditNote if self.can_reword() => { | 1474 | Action::EditNote if self.can_reword() => { |
| 1447 | return vec![Effect::EditNote( | 1475 | return vec![Effect::EditNote( |
| 1448 | self.rows[self.selected.expect("note requires a selection")].id, | 1476 | self.rows[self.selected.expect("note requires a selection")].id, |
| @@ -1783,7 +1811,7 @@ impl App { · 334 unmodified lines | |||
| 1783 | self.pending_hidden_rows = None; | 1811 | self.pending_hidden_rows = None; |
| 1784 | self.titles = Vec::new(); | 1812 | self.titles = Vec::new(); |
| 1785 | self.notes.clear(); | 1813 | self.notes.clear(); |
| 1786 | self.enrichments.clear(); | 1814 | self.clear_enrichments(); |
| 1787 | self.graph = None; | 1815 | self.graph = None; |
| 1788 | self.attributions = Vec::new(); | 1816 | self.attributions = Vec::new(); |
| 1789 | #[cfg(test)] | 1817 | #[cfg(test)] |
| @@ -4271,6 +4299,11 @@ mod tests { · 2481 unmodified lines | |||
| 4271 | vec![Effect::EditGitNote(id(1))], | 4299 | vec![Effect::EditGitNote(id(1))], |
| 4272 | "Git notes are available on every selection" | 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 | assert!(app.enrich_expanded, "the grouped actions keep enrich open"); | 4307 | assert!(app.enrich_expanded, "the grouped actions keep enrich open"); |
| 4275 | 4308 | ||
| 4276 | app.update(Action::MoveDown); | 4309 | app.update(Action::MoveDown); |
| @@ -4291,6 +4324,11 @@ mod tests { · 14 unmodified lines | |||
| 4291 | vec![Effect::EditGitNote(id(1))], | 4324 | vec![Effect::EditGitNote(id(1))], |
| 4292 | "immutable boundaries still accept Git notes" | 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 | #[test] | 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 | let change_ids = crate::change_id::abbreviations(repository, app.rows.iter().map(|row| row.id), 7)?; | 328 | let change_ids = crate::change_id::abbreviations(repository, app.rows.iter().map(|row| row.id), 7)?; |
| 315 | 329 | ||
| 316 | let mailmap = repository.open_mailmap(); | 330 | let mailmap = repository.open_mailmap(); |
| @@ -322,6 +336,7 @@ fn write_history( · 5 unmodified lines | |||
| 322 | Line::raw(crate::enrich::marker( | 336 | Line::raw(crate::enrich::marker( |
| 323 | todo_ids.contains(&row.id), | 337 | todo_ids.contains(&row.id), |
| 324 | enrichment_note_ids.contains(&row.id), | 338 | enrichment_note_ids.contains(&row.id), |
| 339 | checks_pass_ids.contains(&row.id), | ||
| 325 | )) | 340 | )) |
| 326 | .width() | 341 | .width() |
| 327 | }) | 342 | }) |
| @@ -337,8 +352,11 @@ fn write_history( · 9 unmodified lines | |||
| 337 | note_ids.contains(&row.id), | 352 | note_ids.contains(&row.id), |
| 338 | change_ids.values.get(&row.id).copied(), | 353 | change_ids.values.get(&row.id).copied(), |
| 339 | ); | 354 | ); |
| 340 | let enrichment_marker = | 355 | let enrichment_marker = crate::enrich::marker( |
| 341 | crate::enrich::marker(todo_ids.contains(&row.id), enrichment_note_ids.contains(&row.id)); | 356 | todo_ids.contains(&row.id), |
| 357 | enrichment_note_ids.contains(&row.id), | ||
| 358 | checks_pass_ids.contains(&row.id), | ||
| 359 | ); | ||
| 342 | let ambiguity_marker = if change_ids.ambiguous.contains(&row.id) { | 360 | let ambiguity_marker = if change_ids.ambiguous.contains(&row.id) { |
| 343 | "💥" | 361 | "💥" |
| 344 | } else { | 362 | } else { |
| @@ -1150,6 +1168,7 @@ mod tests { · 805 unmodified lines | |||
| 1150 | create_pins(&repository, &[OsString::from(orphan.to_string())])?; | 1168 | create_pins(&repository, &[OsString::from(orphan.to_string())])?; |
| 1151 | let head_change_id = crate::change_id::for_commit(&repository, head)?; | 1169 | let head_change_id = crate::change_id::for_commit(&repository, head)?; |
| 1152 | assert!(crate::enrich::toggle(&repository, head)?.todo); | 1170 | assert!(crate::enrich::toggle(&repository, head)?.todo); |
| 1171 | assert!(crate::enrich::toggle_checks_pass(&repository, head)?.checks_pass); | ||
| 1153 | 1172 | ||
| 1154 | let mut output = Vec::new(); | 1173 | let mut output = Vec::new(); |
| 1155 | write_history(&repository, &[], &[OsString::from("v1")], &mut output)?; | 1174 | write_history(&repository, &[], &[OsString::from("v1")], &mut output)?; |
| @@ -1202,8 +1221,8 @@ mod tests { · 46 unmodified lines | |||
| 1202 | } | 1221 | } |
| 1203 | assert!(output.contains('●'), "history graph lanes are rendered"); | 1222 | assert!(output.contains('●'), "history graph lanes are rendered"); |
| 1204 | assert!( | 1223 | assert!( |
| 1205 | output.lines().any(|line| line.starts_with("🚧💥├")), | 1224 | output.lines().any(|line| line.starts_with("🚧✔️💥├")), |
| 1206 | "todos directly lead their rows: {output:?}" | 1225 | "commit and tree enrichments directly lead their rows: {output:?}" |
| 1207 | ); | 1226 | ); |
| 1208 | assert!(output.contains("📌"), "applicable pins are decorated and traversed"); | 1227 | assert!(output.contains("📌"), "applicable pins are decorated and traversed"); |
| 1209 | assert!( | 1228 | assert!( |
| @@ -8,6 +8,7 @@ use gix::{ · 7 unmodified lines | |||
| 8 | }; | 8 | }; |
| 9 | 9 | ||
| 10 | pub(crate) const REF_NAME: &str = "refs/worktree/tix/enrich"; | 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 | #[derive(Clone, Debug, Default, Eq, PartialEq)] | 13 | #[derive(Clone, Debug, Default, Eq, PartialEq)] |
| 13 | pub(crate) struct Enrichment { | 14 | pub(crate) struct Enrichment { |
| @@ -15,30 +16,47 @@ pub(crate) struct Enrichment { · 1 unmodified line | |||
| 15 | pub note: Option<BString>, | 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 | #[derive(Clone, Debug, Default, Eq, PartialEq)] | 24 | #[derive(Clone, Debug, Default, Eq, PartialEq)] |
| 19 | pub(crate) struct Headers { | 25 | pub(crate) struct Headers { |
| 20 | pub todo: bool, | 26 | pub todo: bool, |
| 21 | pub message: Option<BString>, | 27 | pub message: Option<BString>, |
| 22 | } | 28 | } |
| 23 | 29 | ||
| 24 | pub(crate) fn marker(todo: bool, note: bool) -> &'static str { | 30 | pub(crate) fn marker(todo: bool, note: bool, checks_pass: bool) -> &'static str { |
| 25 | match (todo, note) { | 31 | match (todo, note, checks_pass) { |
| 26 | (true, true) => "🚧📝", | 32 | (true, true, true) => "🚧📝✔️", |
| 27 | (true, false) => "🚧", | 33 | (true, true, false) => "🚧📝", |
| 28 | (false, true) => "📝", | 34 | (true, false, true) => "🚧✔️", |
| 29 | (false, false) => "", | 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 | pub(crate) fn open(repo: &gix::Repository) -> Result<gix::note::Platform<'_>> { | 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 | repo.notes() | 52 | repo.notes() |
| 35 | .context("could not open tix enrichments")? | 53 | .context("could not open tix enrichments")? |
| 36 | .with_refs([REF_NAME]) | 54 | .with_refs([reference]) |
| 37 | .context("could not select the tix enrich reference") | 55 | .context("could not select the tix enrich reference") |
| 38 | } | 56 | } |
| 39 | 57 | ||
| 40 | pub(crate) fn load(notes: &mut gix::note::Platform, change_id: ChangeId) -> Result<Enrichment> { | 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 | let Some(config) = config else { | 60 | let Some(config) = config else { |
| 43 | return Ok(Enrichment::default()); | 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>> { | 72 | pub(crate) fn load_tree(notes: &mut gix::note::Platform, tree_id: ObjectId) -> Result<TreeEnrichment> { |
| 55 | let found = notes | 73 | let Some(config) = load_config(notes, tree_id)? else { |
| 56 | .get(ObjectId::from(change_id)) | 74 | return Ok(TreeEnrichment::default()); |
| 57 | .context("could not load the tix enrichment")?; | 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 | found | 86 | found |
| 59 | .first() | 87 | .first() |
| 60 | .map(|note| { | 88 | .map(|note| { |
| @@ -63,6 +91,14 @@ fn load_config(notes: &mut gix::note::Platform, change_id: ChangeId) -> Result<O · 2 unmodified lines | |||
| 63 | .transpose() | 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 | pub(crate) fn toggle(repo: &gix::Repository, commit_id: ObjectId) -> Result<Enrichment> { | 102 | pub(crate) fn toggle(repo: &gix::Repository, commit_id: ObjectId) -> Result<Enrichment> { |
| 67 | update(repo, commit_id, |config| { | 103 | update(repo, commit_id, |config| { |
| 68 | let enabled = !config | 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 | pub(crate) fn apply_headers( | 150 | pub(crate) fn apply_headers( |
| 99 | repo: &gix::Repository, | 151 | repo: &gix::Repository, |
| 100 | commit_id: ObjectId, | 152 | commit_id: ObjectId, |
| @@ -155,7 +207,7 @@ fn update( · 54 unmodified lines | |||
| 155 | ) -> Result<Enrichment> { | 207 | ) -> Result<Enrichment> { |
| 156 | let change_id = crate::change_id::for_commit(repo, commit_id)?; | 208 | let change_id = crate::change_id::for_commit(repo, commit_id)?; |
| 157 | let mut notes = open(repo)?; | 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 | edit(&mut config)?; | 211 | edit(&mut config)?; |
| 160 | let reference: FullName = REF_NAME.try_into().expect("the tix enrich reference is valid"); | 212 | let reference: FullName = REF_NAME.try_into().expect("the tix enrich reference is valid"); |
| 161 | notes | 213 | notes |
| @@ -164,6 +216,23 @@ fn update( · 2 unmodified lines | |||
| 164 | load(&mut notes, change_id) | 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 | #[cfg(test)] | 236 | #[cfg(test)] |
| 168 | mod tests { | 237 | mod tests { |
| 169 | use std::process::Command; | 238 | use std::process::Command; |
| @@ -224,6 +293,46 @@ mod tests { · 54 unmodified lines | |||
| 224 | Ok(()) | 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 | #[test] | 336 | #[test] |
| 228 | fn notes_and_todos_are_independent() -> gix_testtools::Result { | 337 | fn notes_and_todos_are_independent() -> gix_testtools::Result { |
| 229 | let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; | 338 | let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; |
| @@ -316,6 +425,13 @@ mod tests { · 86 unmodified lines | |||
| 316 | toggle(&repo, id).is_err(), | 425 | toggle(&repo, id).is_err(), |
| 317 | "mutation does not replace malformed enrichments" | 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 | Ok(()) | 435 | Ok(()) |
| 320 | } | 436 | } |
| 321 | 437 | ||
| @@ -347,6 +463,12 @@ mod tests { · 25 unmodified lines | |||
| 347 | load(&mut open(&linked)?, change_id)?.todo, | 463 | load(&mut open(&linked)?, change_id)?.todo, |
| 348 | "linked enrichments survive main-worktree changes" | 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 | Ok(()) | 472 | Ok(()) |
| 351 | } | 473 | } |
| 352 | } | 474 | } |
| @@ -2702,6 +2702,26 @@ fn event_loop( · 2701 unmodified lines | |||
| 2702 | Err(err) => app.leave_error(format!("todo: {err:#}")), | 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 | Effect::EditNote(id) => { | 2725 | Effect::EditNote(id) => { |
| 2706 | fill_repository.retain = false; | 2726 | fill_repository.retain = false; |
| 2707 | fill_repository.retained = None; | 2727 | fill_repository.retained = None; |
| @@ -3119,6 +3139,11 @@ fn draw( · 411 unmodified lines | |||
| 3119 | .map(|row| row.id) | 3139 | .map(|row| row.id) |
| 3120 | .filter(|id| repository_fill_allowed && !app.enrichment_loaded(*id)) | 3140 | .filter(|id| repository_fill_allowed && !app.enrichment_loaded(*id)) |
| 3121 | .collect(); | 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 | let changes_visible = app.changes_visible(); | 3147 | let changes_visible = app.changes_visible(); |
| 3123 | let selected_id = app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id); | 3148 | let selected_id = app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id); |
| 3124 | app.selection_relation = selection_cache | 3149 | app.selection_relation = selection_cache |
| @@ -3193,6 +3218,7 @@ fn draw( · 68 unmodified lines | |||
| 3193 | } | 3218 | } |
| 3194 | if !notes_to_load.is_empty() | 3219 | if !notes_to_load.is_empty() |
| 3195 | || !enrichments_to_load.is_empty() | 3220 | || !enrichments_to_load.is_empty() |
| 3221 | || !tree_enrichments_to_load.is_empty() | ||
| 3196 | || app.rows[start..end].iter().any(|row| !row.metadata_loaded) | 3222 | || app.rows[start..end].iter().any(|row| !row.metadata_loaded) |
| 3197 | || message_to_load.is_some() | 3223 | || message_to_load.is_some() |
| 3198 | || tree_changes_to_load.is_some() | 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 | for index in start..end { | 3278 | for index in start..end { |
| 3240 | if app.rows[index].metadata_loaded { | 3279 | if app.rows[index].metadata_loaded { |
| 3241 | continue; | 3280 | continue; |
| @@ -5085,6 +5124,7 @@ fn action_with_shortcut_groups( · 1843 unmodified lines | |||
| 5085 | KeyCode::Char('s') if commit_expanded => Some(Action::Spill), | 5124 | KeyCode::Char('s') if commit_expanded => Some(Action::Spill), |
| 5086 | KeyCode::Char('d') if commit_expanded => Some(Action::Forget), | 5125 | KeyCode::Char('d') if commit_expanded => Some(Action::Forget), |
| 5087 | KeyCode::Char('i') if commit_expanded => Some(Action::TogglePin), | 5126 | KeyCode::Char('i') if commit_expanded => Some(Action::TogglePin), |
| 5127 | KeyCode::Char('c') if enrich_expanded => Some(Action::ToggleChecksPass), | ||
| 5088 | KeyCode::Char('t') if enrich_expanded => Some(Action::ToggleTodo), | 5128 | KeyCode::Char('t') if enrich_expanded => Some(Action::ToggleTodo), |
| 5089 | KeyCode::Char('o') if enrich_expanded => Some(Action::EditNote), | 5129 | KeyCode::Char('o') if enrich_expanded => Some(Action::EditNote), |
| 5090 | KeyCode::Char('@') => Some(Action::TimeTravel), | 5130 | KeyCode::Char('@') => Some(Action::TimeTravel), |
| @@ -6179,6 +6219,17 @@ mod tests { · 1088 unmodified lines | |||
| 6179 | action(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)), | 6219 | action(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)), |
| 6180 | Some(Action::ToggleEnrich) | 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 | assert_eq!( | 6233 | assert_eq!( |
| 6183 | action_with_shortcut_groups( | 6234 | action_with_shortcut_groups( |
| 6184 | KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), | 6235 | KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), |
| @@ -479,7 +479,14 @@ pub(crate) fn draw_with_worktree( · 478 unmodified lines | |||
| 479 | let lanes = app.render_lanes(start..render_end); | 479 | let lanes = app.render_lanes(start..render_end); |
| 480 | let enrichment_gutter = visible_rows | 480 | let enrichment_gutter = visible_rows |
| 481 | .iter() | 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 | .max() | 490 | .max() |
| 484 | .unwrap_or_default() as u16; | 491 | .unwrap_or_default() as u16; |
| 485 | let change_id_gutter = if visible_rows.iter().any(|row| app.has_duplicate_change_id(row.id)) { | 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 | let enrichment_marker = crate::enrich::marker( | 699 | let enrichment_marker = crate::enrich::marker( |
| 693 | app.todo(visible_rows[index].id), | 700 | app.todo(visible_rows[index].id), |
| 694 | app.note(visible_rows[index].id).is_some(), | 701 | app.note(visible_rows[index].id).is_some(), |
| 702 | app.checks_pass(visible_rows[index].id), | ||
| 695 | ); | 703 | ); |
| 696 | if !enrichment_marker.is_empty() { | 704 | if !enrichment_marker.is_empty() { |
| 697 | frame.render_widget( | 705 | frame.render_widget( |
| @@ -1154,6 +1162,8 @@ pub(crate) fn draw_with_worktree( · 456 unmodified lines | |||
| 1154 | ordered.extend(shortcut("note", 'o', app.note(row.id).is_some())); | 1162 | ordered.extend(shortcut("note", 'o', app.note(row.id).is_some())); |
| 1155 | ordered.push(Span::raw(" · ")); | 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 | ordered.extend(shortcut("git note", 'g', !app.notes(row.id).is_empty())); | 1167 | ordered.extend(shortcut("git note", 'g', !app.notes(row.id).is_empty())); |
| 1158 | } else { | 1168 | } else { |
| 1159 | ordered.push(Span::raw("no actions")); | 1169 | ordered.push(Span::raw("no actions")); |
| @@ -2656,14 +2666,16 @@ mod tests { · 1496 unmodified lines | |||
| 2656 | note: Some("follow-up *title*\n\nwhy it matters\n".into()), | 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 | let mut terminal = Terminal::new(TestBackend::new(80, 2))?; | 2670 | let mut terminal = Terminal::new(TestBackend::new(80, 2))?; |
| 2660 | terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; | 2671 | terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; |
| 2661 | 2672 | ||
| 2662 | let row = terminal.backend().buffer(); | 2673 | let row = terminal.backend().buffer(); |
| 2663 | assert_eq!(row[(0, 0)].symbol(), "🚧", "todo leads the row"); | 2674 | assert_eq!(row[(0, 0)].symbol(), "🚧", "todo leads the row"); |
| 2664 | assert_eq!(row[(2, 0)].symbol(), "📝", "note directly follows todo"); | 2675 | assert_eq!(row[(2, 0)].symbol(), "📝", "note directly follows todo"); |
| 2665 | assert_eq!(row[(4, 0)].symbol(), ">", "selection directly follows enrichments"); | 2676 | assert_eq!(row[(4, 0)].symbol(), "✔️", "tree status follows commit enrichments"); |
| 2666 | assert_eq!(row[(6, 0)].symbol(), "●", "the graph remains separate"); | 2677 | assert_eq!(row[(6, 0)].symbol(), ">", "selection directly follows enrichments"); |
| 2678 | assert_eq!(row[(8, 0)].symbol(), "●", "the graph remains separate"); | ||
| 2667 | assert!( | 2679 | assert!( |
| 2668 | rendered_line(&terminal, 0).contains("follow-up title"), | 2680 | rendered_line(&terminal, 0).contains("follow-up title"), |
| 2669 | "the selected todo displays its note title" | 2681 | "the selected todo displays its note title" |
| @@ -2680,7 +2692,7 @@ mod tests { · 10 unmodified lines | |||
| 2680 | app.enrich_expanded = true; | 2692 | app.enrich_expanded = true; |
| 2681 | terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; | 2693 | terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; |
| 2682 | assert!( | 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 | "the enrich group advertises all note actions" | 2696 | "the enrich group advertises all note actions" |
| 2685 | ); | 2697 | ); |
| 2686 | app.enrich_expanded = false; | 2698 | app.enrich_expanded = false; |
| @@ -2705,8 +2717,9 @@ mod tests { · 18 unmodified lines | |||
| 2705 | "a note replaces the selected title independently of todo" | 2717 | "a note replaces the selected title independently of todo" |
| 2706 | ); | 2718 | ); |
| 2707 | assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), "📝"); | 2719 | assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), "📝"); |
| 2708 | assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), ">"); | 2720 | assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "✔️"); |
| 2709 | assert_eq!(terminal.backend().buffer()[(4, 0)].symbol(), "●"); | 2721 | assert_eq!(terminal.backend().buffer()[(4, 0)].symbol(), ">"); |
| 2722 | assert_eq!(terminal.backend().buffer()[(6, 0)].symbol(), "●"); | ||
| 2710 | Ok(()) | 2723 | Ok(()) |
| 2711 | } | 2724 | } |
| 2712 | 2725 | ||