History

feat: add checks-pass tree enrichments

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.

Codex committed 2 months ago

Commit b32ffbc1 parent 254f9356 files · +278 −28Browse files

Unified · Split

6 files
  1. gix-tix/spec.md +10 −3
  2. gix-tix/src/app.rs +40 −2
  3. gix-tix/src/command.rs +23 −4
  4. gix-tix/src/enrich.rs +135 −13
  5. gix-tix/src/lib.rs +51 −0
  6. gix-tix/src/ui.rs +19 −6
gix-tix/spec.md+10−3
@@ -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 a247 Consequently, rewritten commits retain their metadata and commits sharing a
248 change ID share it as well. Malformed enrichments are ignored for display and248 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 before250- 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 dedicated256 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 displayed258 `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` opens1020 `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 preserves1021 `[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 Git1022 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 mutually1023 `[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 follows1026 exclusive with the view, commit, actions, and information groups and otherwise follows
1020 their closing behavior.1027 their closing behavior.
1021 1028
gix-tix/src/app.rs+40−2
@@ -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::EditGitNote1090 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]
gix-tix/src/command.rs+23−4
@@ -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!(
gix-tix/src/enrich.rs+135−13
@@ -8,6 +8,7 @@ use gix::{ · 7 unmodified lines
8};8};
9 9
10pub(crate) const REF_NAME: &str = "refs/worktree/tix/enrich";10pub(crate) const REF_NAME: &str = "refs/worktree/tix/enrich";
11pub(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)]
13pub(crate) struct Enrichment {14pub(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)]
20pub(crate) struct TreeEnrichment {
21 pub checks_pass: bool,
22}
23
18#[derive(Clone, Debug, Default, Eq, PartialEq)]24#[derive(Clone, Debug, Default, Eq, PartialEq)]
19pub(crate) struct Headers {25pub(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
24pub(crate) fn marker(todo: bool, note: bool) -> &'static str {30pub(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
33pub(crate) fn open(repo: &gix::Repository) -> Result<gix::note::Platform<'_>> {43pub(crate) fn open(repo: &gix::Repository) -> Result<gix::note::Platform<'_>> {
44 open_at(repo, REF_NAME)
45}
46
47pub(crate) fn open_tree(repo: &gix::Repository) -> Result<gix::note::Platform<'_>> {
48 open_at(repo, TREE_REF_NAME)
49}
50
51fn 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
40pub(crate) fn load(notes: &mut gix::note::Platform, change_id: ChangeId) -> Result<Enrichment> {58pub(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
54fn load_config(notes: &mut gix::note::Platform, change_id: ChangeId) -> Result<Option<File>> {72pub(crate) fn load_tree(notes: &mut gix::note::Platform, tree_id: ObjectId) -> Result<TreeEnrichment> {
55 let found = notes73 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
84fn 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 found86 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
94pub(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
66pub(crate) fn toggle(repo: &gix::Repository, commit_id: ObjectId) -> Result<Enrichment> {102pub(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 = !config104 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
134pub(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
98pub(crate) fn apply_headers(150pub(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 notes213 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
219fn 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)]
168mod tests {237mod 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}
gix-tix/src/lib.rs+51−0
@@ -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_cache3149 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),
gix-tix/src/ui.rs+19−6
@@ -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_rows480 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

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t