1use serde::Deserialize;
46
47use crate::error::{Result, TixError};
48use crate::sync::{CommandOutcome, CommandRunner, RemoteIssue, Tool, TrackerAdapter, scrub_secret};
49
50pub struct LinearAdapter {
54 runner: Box<dyn CommandRunner>,
55 token: Option<String>,
56 team: String,
57}
58
59impl LinearAdapter {
60 pub fn new(runner: impl CommandRunner + 'static, token: Option<String>, team: String) -> Self {
61 LinearAdapter {
62 runner: Box::new(runner),
63 token,
64 team,
65 }
66 }
67
68 fn child_env(&self) -> Vec<(String, String)> {
69 match &self.token {
70 Some(token) => vec![("LINEAR_API_KEY".to_string(), token.clone())],
71 None => Vec::new(),
72 }
73 }
74
75 fn run(&self, args: &[String]) -> Result<CommandOutcome> {
76 self.runner
77 .run(Tool::Linear.binary(), args, &self.child_env())
78 }
79
80 fn external(&self, context: &str, outcome: &CommandOutcome) -> TixError {
81 let mut message = format!("{context}: {}", outcome.text().trim());
82 if let Some(secret) = &self.token {
83 message = scrub_secret(&message, secret);
84 }
85 TixError::External(message)
86 }
87
88 fn parse_identifier(&self, outcome: &CommandOutcome) -> Result<String> {
90 serde_json::from_str::<serde_json::Value>(&outcome.stdout)
91 .ok()
92 .and_then(|value| {
93 value
94 .get("identifier")
95 .and_then(serde_json::Value::as_str)
96 .map(str::to_string)
97 })
98 .ok_or_else(|| {
99 TixError::External(format!(
100 "`linear-cli issues create` did not return JSON with an `identifier` field (expected {{\"identifier\":…}}): {}",
101 scrub_secret(outcome.stdout.trim(), self.token.as_deref().unwrap_or(""))
102 ))
103 })
104 }
105
106 fn fetch_issue(&self, identifier: &str) -> Result<RemoteIssue> {
108 let args: Vec<String> = vec![
109 "issues".into(),
110 "get".into(),
111 "-o".into(),
112 "json".into(),
113 identifier.to_string(),
114 ];
115 let outcome = self.run(&args)?;
116 if !outcome.success {
117 return Err(self.external("`linear-cli issues get` failed", &outcome));
118 }
119 let detail: LinearDetailIssue = serde_json::from_str(&outcome.stdout).map_err(|e| {
120 TixError::External(format!(
121 "`linear-cli issues get` did not return a JSON object: {e}\n{}",
122 scrub_secret(&outcome.stdout, self.token.as_deref().unwrap_or(""))
123 ))
124 })?;
125 Ok(RemoteIssue::from(detail))
126 }
127}
128
129impl TrackerAdapter for LinearAdapter {
130 fn tool(&self) -> Tool {
131 Tool::Linear
132 }
133
134 fn create_issue(
135 &mut self,
136 title: &str,
137 body: &str,
138 labels: &[String],
139 _assignee: Option<&str>,
140 priority: Option<&str>,
141 status: &str,
142 ) -> Result<String> {
143 let mut args: Vec<String> = vec![
144 "issues".into(),
145 "create".into(),
146 format!("-t={}", self.team),
147 format!("-d={body}"),
148 ];
149 for label in labels {
150 args.push(format!("-l={label}"));
151 }
152 if let Some(p) = priority.and_then(linear_priority) {
153 args.push(format!("-p={p}"));
154 }
155 args.push(format!("-s={status}"));
156 args.push("-o".into());
157 args.push("json".into());
158 args.push("--".into());
160 args.push(title.to_string());
161 let outcome = self.run(&args)?;
162 if !outcome.success {
163 return Err(self.external("`linear-cli issues create` failed", &outcome));
164 }
165 self.parse_identifier(&outcome)
166 }
167
168 fn update_issue(
169 &mut self,
170 remote_ref: &str,
171 title: &str,
172 body: &str,
173 labels: &[String],
174 _assignee: Option<&str>,
175 priority: Option<&str>,
176 status: &str,
177 ) -> Result<()> {
178 let mut args: Vec<String> = vec![
179 "issues".into(),
180 "update".into(),
181 format!("-T={title}"),
182 format!("-d={body}"),
183 ];
184 for label in labels {
185 args.push(format!("-l={label}"));
186 }
187 if let Some(p) = priority.and_then(linear_priority) {
188 args.push(format!("-p={p}"));
189 }
190 args.push(format!("-s={status}"));
191 args.push("--".into());
193 args.push(remote_ref.to_string());
194 let outcome = self.run(&args)?;
195 if !outcome.success {
196 return Err(self.external("`linear-cli issues update` failed", &outcome));
197 }
198 Ok(())
199 }
200
201 fn mark_done(&mut self, _remote_ref: &str) -> Result<()> {
203 Ok(())
204 }
205
206 fn list_open(&mut self) -> Result<Vec<RemoteIssue>> {
213 let args: Vec<String> = vec![
214 "issues".into(),
215 "list".into(),
216 format!("-t={}", self.team),
217 "-o".into(),
218 "json".into(),
219 ];
220 let outcome = self.run(&args)?;
221 if !outcome.success {
222 return Err(self.external("`linear-cli issues list` failed", &outcome));
223 }
224 let listed: Vec<LinearListIssue> = serde_json::from_str(&outcome.stdout).map_err(|e| {
225 TixError::External(format!(
226 "`linear-cli issues list` did not return a JSON array: {e}\n{}",
227 scrub_secret(&outcome.stdout, self.token.as_deref().unwrap_or(""))
228 ))
229 })?;
230 let mut issues = Vec::new();
231 for item in listed {
232 if item.state_name() == "Done" {
233 continue;
234 }
235 issues.push(self.fetch_issue(&item.identifier)?);
236 }
237 Ok(issues)
238 }
239
240 fn done_via_status(&self) -> bool {
241 true
242 }
243
244 fn set_relations(
255 &mut self,
256 remote_ref: &str,
257 relations: &[super::MappedRelation],
258 ) -> Result<()> {
259 for relation in relations {
260 let super::MappedRelation::Remote { field, value } = relation else {
261 continue;
262 };
263 let args: Vec<String> = vec![
264 "relations".into(),
265 "add".into(),
266 "-r".into(),
267 field.clone(),
268 remote_ref.to_string(),
269 value.clone(),
270 ];
271 let outcome = self.run(&args)?;
272 if !outcome.success {
273 return Err(self.external("`linear-cli relations add` failed", &outcome));
274 }
275 }
276 Ok(())
277 }
278
279 fn set_projects(&mut self, _remote_ref: &str, projects: &[super::MappedProject]) -> Result<()> {
293 debug_assert!(
294 projects.is_empty(),
295 "Linear's issue carries one `project`, so no membership list can be \
296 sent: flip `PROJECT_WIRE_VERIFIED` only together with an \
297 implementation here"
298 );
299 Ok(())
300 }
301
302 fn set_epics(&mut self, _remote_ref: &str, epics: &[super::MappedEpic]) -> Result<()> {
315 debug_assert!(
316 epics.is_empty(),
317 "Linear has no epic level, so no epic reference list can be sent: flip \
318 `EPIC_WIRE_VERIFIED` only together with an implementation here"
319 );
320 Ok(())
321 }
322}
323
324#[must_use]
335fn linear_priority(priority: &str) -> Option<&'static str> {
336 match priority {
337 "low" => Some("4"),
338 "medium" => Some("3"),
339 "high" => Some("2"),
340 "" => Some("0"),
341 _ => None,
342 }
343}
344
345#[derive(Debug, Deserialize)]
348struct LinearListIssue {
349 identifier: String,
350 #[serde(default, alias = "state")]
351 state: Option<LinearStatus>,
352}
353
354impl LinearListIssue {
355 fn state_name(&self) -> &str {
357 self.state.as_ref().map(LinearStatus::name).unwrap_or("")
358 }
359}
360
361#[derive(Debug, Deserialize)]
366struct LinearDetailIssue {
367 identifier: String,
368 #[serde(default)]
369 title: String,
370 #[serde(default)]
371 description: Option<String>,
372 #[serde(default, alias = "state")]
373 state: Option<LinearStatus>,
374 #[serde(default, alias = "updatedAt")]
375 updated_at: Option<String>,
376 #[serde(default)]
377 assignee: Option<LinearAssignee>,
378 #[serde(default)]
379 labels: Option<LinearLabels>,
380 #[serde(default)]
382 parent: Option<serde_json::Value>,
383 #[serde(default, alias = "projects")]
385 project: Option<serde_json::Value>,
386}
387
388#[derive(Debug, Deserialize)]
391struct LinearAssignee {
392 #[serde(default)]
393 name: Option<String>,
394}
395
396#[derive(Debug, Deserialize)]
398struct LinearLabels {
399 #[serde(default)]
400 nodes: Vec<LinearLabelNode>,
401}
402
403#[derive(Debug, Deserialize)]
404struct LinearLabelNode {
405 #[serde(default)]
406 name: String,
407}
408
409#[derive(Debug, Deserialize)]
411#[serde(untagged)]
412enum LinearStatus {
413 Name(String),
414 Named { name: String },
415}
416
417impl LinearStatus {
418 fn name(&self) -> &str {
419 match self {
420 LinearStatus::Name(name) | LinearStatus::Named { name } => name,
421 }
422 }
423}
424
425impl From<LinearDetailIssue> for RemoteIssue {
426 fn from(issue: LinearDetailIssue) -> Self {
427 RemoteIssue {
428 id: issue.identifier,
429 title: issue.title,
430 description: issue.description.unwrap_or_default(),
431 labels: issue
432 .labels
433 .map(|labels| labels.nodes.into_iter().map(|n| n.name).collect())
434 .unwrap_or_default(),
435 state: issue
436 .state
437 .as_ref()
438 .map(LinearStatus::name)
439 .unwrap_or("")
440 .to_string(),
441 updated_at: issue.updated_at,
442 assignee: issue.assignee.and_then(|a| a.name),
443 parent: linear_parent_id(issue.parent.as_ref()),
449 projects: crate::sync::remote_projects(issue.project.as_ref()),
450 epics: Vec::new(),
453 }
454 }
455}
456
457fn linear_parent_id(value: Option<&serde_json::Value>) -> Option<String> {
464 fn one(value: &serde_json::Value) -> Option<String> {
465 match value {
466 serde_json::Value::String(s) => Some(s.clone()),
467 serde_json::Value::Object(map) => map
468 .get("id")
469 .or_else(|| map.get("identifier"))
470 .and_then(|v| v.as_str())
471 .map(str::to_string),
472 _ => None,
473 }
474 }
475 match value? {
476 serde_json::Value::Array(items) => items.first().and_then(one),
477 other => one(other),
478 }
479}
480
481impl CommandOutcome {
483 #[must_use]
484 pub fn linear_created(identifier: &str) -> CommandOutcome {
485 CommandOutcome {
486 success: true,
487 status: Some(0),
488 stdout: format!(
489 "{{\"identifier\": \"{identifier}\", \"url\": \"https://linear.app/acme/issue/{identifier}\"}}"
490 ),
491 stderr: String::new(),
492 }
493 }
494
495 #[must_use]
496 pub fn linear_list(payload: &str) -> CommandOutcome {
497 CommandOutcome {
498 success: true,
499 status: Some(0),
500 stdout: payload.to_string(),
501 stderr: String::new(),
502 }
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 #[allow(clippy::wildcard_imports)]
509 use super::*;
510 use crate::sync::MappedRelation;
511 use crate::sync::glab::{FakeRunner, RecordedCall};
512
513 fn adapter_with(runner: FakeRunner, token: Option<&str>) -> LinearAdapter {
514 LinearAdapter::new(runner, token.map(String::from), "Eng".into())
515 }
516
517 fn recorded(adapter: &LinearAdapter) -> Vec<RecordedCall> {
518 adapter
519 .runner
520 .as_any()
521 .downcast_ref::<FakeRunner>()
522 .expect("adapter must hold a FakeRunner")
523 .take_calls()
524 }
525
526 #[test]
527 fn create_attaches_values_and_puts_the_positional_title_behind_double_dash() {
528 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
529 let mut adapter = adapter_with(runner, Some("lin-token"));
530 let id = adapter
531 .create_issue("Fix", "body", &["bug".into()], None, Some("high"), "Done")
532 .unwrap();
533 assert_eq!(id, "TIX-7");
534 let (argv, env) = &recorded(&adapter)[0];
535 assert_eq!(
536 argv.join(" "),
537 "linear-cli issues create -t=Eng -d=body -l=bug -p=2 -s=Done -o json -- Fix"
538 );
539 assert_eq!(
540 env,
541 &[("LINEAR_API_KEY".to_string(), "lin-token".to_string())]
542 );
543 }
544
545 #[test]
549 fn create_survives_a_hyphen_leading_body_and_title() {
550 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
551 let mut adapter = adapter_with(runner, None);
552 adapter
553 .create_issue(
554 "-leading title",
555 "- bullet one\n- bullet two",
556 &["bug".into()],
557 None,
558 None,
559 "Todo",
560 )
561 .unwrap();
562 let argv = &recorded(&adapter)[0].0;
563 assert_eq!(
564 argv.join(" "),
565 "linear-cli issues create -t=Eng -d=- bullet one\n- bullet two -l=bug -s=Todo -o json -- -leading title"
566 );
567 assert_eq!(
568 argv.last().unwrap(),
569 "-leading title",
570 "the title is the positional"
571 );
572 }
573
574 #[test]
575 fn update_survives_a_hyphen_leading_title_and_body() {
576 let runner = FakeRunner::new(vec![]);
577 let mut adapter = adapter_with(runner, None);
578 adapter
579 .update_issue("TIX-7", "-tricky title", "- body", &[], None, None, "Done")
580 .unwrap();
581 let argv = &recorded(&adapter)[0].0;
582 assert_eq!(
583 argv.join(" "),
584 "linear-cli issues update -T=-tricky title -d=- body -s=Done -- TIX-7"
585 );
586 }
587
588 #[test]
591 fn the_token_is_injected_under_the_name_the_tool_reads() {
592 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-1")]);
593 let mut adapter = adapter_with(runner, Some("lin-token"));
594 adapter
595 .create_issue("T", "b", &[], None, None, "Todo")
596 .unwrap();
597 assert_eq!(
598 &recorded(&adapter)[0].1,
599 &[("LINEAR_API_KEY".to_string(), "lin-token".to_string())]
600 );
601 }
602
603 #[test]
604 fn create_without_labels_omits_label_flag() {
605 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-8")]);
606 let mut adapter = adapter_with(runner, None);
607 adapter
608 .create_issue("T", "b", &[], None, None, "todo")
609 .unwrap();
610 let joined = recorded(&adapter)[0].0.join(" ");
611 assert!(!joined.contains("-l="), "{joined}");
612 }
613
614 #[test]
615 fn priority_maps_to_linear_numbers_and_unknown_is_omitted() {
616 assert_eq!(linear_priority("low"), Some("4"));
617 assert_eq!(linear_priority("medium"), Some("3"));
618 assert_eq!(linear_priority("high"), Some("2"));
619 assert_eq!(linear_priority(""), Some("0"), "the empty string is `none`");
620 for unknown in ["urgent", "none", "MEDIUM", "custom-word"] {
621 assert_eq!(
622 linear_priority(unknown),
623 None,
624 "{unknown} must not be coerced to a number"
625 );
626 }
627 }
628
629 #[test]
630 fn an_unmappable_priority_omits_the_priority_flag() {
631 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-9")]);
632 let mut adapter = adapter_with(runner, None);
633 adapter
634 .create_issue("T", "b", &[], None, Some("urgent"), "Todo")
635 .unwrap();
636 let joined = recorded(&adapter)[0].0.join(" ");
637 assert!(
638 !joined.contains("-p="),
639 "unknown priority must be omitted: {joined}"
640 );
641 }
642
643 #[test]
644 fn update_passes_identifier_title_body_labels_priority_and_state() {
645 let runner = FakeRunner::new(vec![]);
646 let mut adapter = adapter_with(runner, None);
647 adapter
648 .update_issue(
649 "TIX-7",
650 "New title",
651 "new body",
652 &["bug".into()],
653 None,
654 Some("low"),
655 "Done",
656 )
657 .unwrap();
658 let joined = recorded(&adapter)[0].0.join(" ");
659 assert_eq!(
660 joined,
661 "linear-cli issues update -T=New title -d=new body -l=bug -p=4 -s=Done -- TIX-7"
662 );
663 }
664
665 #[test]
666 fn done_via_status_is_true_and_mark_done_is_a_noop() {
667 let runner = FakeRunner::new(vec![]);
668 let mut adapter = adapter_with(runner, None);
669 assert!(adapter.done_via_status());
670 adapter.mark_done("TIX-7").unwrap();
671 assert!(recorded(&adapter).is_empty(), "no close call for linear");
672 }
673
674 #[test]
675 fn set_relations_adds_one_edge_per_relation_with_remote_identifiers() {
676 let runner = FakeRunner::new(vec![
677 CommandOutcome::linear_list(""),
678 CommandOutcome::linear_list(""),
679 ]);
680 let mut adapter = adapter_with(runner, None);
681 adapter
682 .set_relations(
683 "TIX-1",
684 &[
685 MappedRelation::Remote {
686 field: "blocks".into(),
687 value: "TIX-2".into(),
688 },
689 MappedRelation::Remote {
690 field: "blocks".into(),
691 value: "TIX-3".into(),
692 },
693 ],
694 )
695 .unwrap();
696 let calls = recorded(&adapter);
697 assert_eq!(
698 calls[0].0.join(" "),
699 "linear-cli relations add -r blocks TIX-1 TIX-2"
700 );
701 assert_eq!(
702 calls[1].0.join(" "),
703 "linear-cli relations add -r blocks TIX-1 TIX-3"
704 );
705 }
706
707 #[test]
711 fn list_open_enumerates_then_fetches_each_open_issues_detail() {
712 let list = r#"[
713 {"id":"u1","identifier":"TIX-1","title":"T1","priority":3,"state":{"name":"In Progress"}},
714 {"id":"u2","identifier":"TIX-2","title":"T2","state":{"name":"Done"}}
715 ]"#;
716 let detail = r##"{"id":"u1","identifier":"TIX-1","title":"T1","description":"full body",
717 "updatedAt":"2026-05-11T10:00:00Z","state":{"name":"In Progress"},
718 "assignee":{"name":"Al","email":"al@example.com"},
719 "labels":{"nodes":[{"name":"bug","color":"#f00"},{"name":"api","color":"#0f0"}]}}"##;
720 let runner = FakeRunner::new(vec![
723 CommandOutcome::linear_list(detail),
724 CommandOutcome::linear_list(list),
725 ]);
726 let mut adapter = adapter_with(runner, None);
727 let issues = adapter.list_open().unwrap();
728 assert_eq!(issues.len(), 1, "the Done issue is filtered before the get");
729 assert_eq!(issues[0].id, "TIX-1");
730 assert_eq!(issues[0].title, "T1");
731 assert_eq!(issues[0].description, "full body");
732 assert_eq!(issues[0].labels, ["bug", "api"]);
733 assert_eq!(issues[0].state, "In Progress");
734 assert_eq!(issues[0].assignee.as_deref(), Some("Al"));
735 assert_eq!(
736 issues[0].updated_at.as_deref(),
737 Some("2026-05-11T10:00:00Z")
738 );
739 let calls = recorded(&adapter);
740 assert_eq!(
741 calls.len(),
742 2,
743 "one list call and one get — never a get for the Done issue"
744 );
745 assert_eq!(
746 calls[0].0.join(" "),
747 "linear-cli issues list -t=Eng -o json"
748 );
749 assert_eq!(calls[1].0.join(" "), "linear-cli issues get -o json TIX-1");
750 }
751
752 #[test]
755 fn a_list_payload_without_detail_fields_does_not_fail_the_import() {
756 let list = r#"[{"id":"u1","identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}]"#;
757 let detail = r#"{"identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}"#;
758 let runner = FakeRunner::new(vec![
759 CommandOutcome::linear_list(detail),
760 CommandOutcome::linear_list(list),
761 ]);
762 let mut adapter = adapter_with(runner, None);
763 let issues = adapter.list_open().unwrap();
764 assert_eq!(issues.len(), 1);
765 assert_eq!(issues[0].description, "");
766 assert_eq!(issues[0].labels, [] as [std::string::String; 0]);
767 assert_eq!(issues[0].updated_at, None);
768 }
769
770 #[test]
771 fn create_json_without_identifier_is_a_parse_error() {
772 let runner = FakeRunner::new(vec![CommandOutcome::linear_list("{}")]);
773 let mut adapter = adapter_with(runner, None);
774 let err = adapter
775 .create_issue("T", "b", &[], None, None, "todo")
776 .unwrap_err();
777 assert!(matches!(err, TixError::External(_)));
778 assert!(err.to_string().contains("identifier"));
779 assert!(
780 err.to_string().contains("linear-cli issues create"),
781 "the error must name the real subcommand: {err}"
782 );
783 }
784
785 #[test]
786 fn a_detail_without_the_identifier_is_a_parse_error() {
787 let list = r#"[{"identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}]"#;
788 let runner = FakeRunner::new(vec![
789 CommandOutcome::linear_list("{}"),
790 CommandOutcome::linear_list(list),
791 ]);
792 let mut adapter = adapter_with(runner, None);
793 let err = adapter.list_open().unwrap_err();
794 assert!(matches!(err, TixError::External(_)));
795 assert!(
796 err.to_string().contains("linear-cli issues get"),
797 "the error names the failing verb: {err}"
798 );
799 }
800
801 #[test]
802 fn failure_message_scrubs_token_from_tool_output() {
803 let runner = FakeRunner::new(vec![CommandOutcome {
804 success: false,
805 status: Some(1),
806 stdout: String::new(),
807 stderr: "LINEAR_API_KEY=lin-token leaked".into(),
808 }]);
809 let mut adapter = adapter_with(runner, Some("lin-token"));
810 let err = adapter
811 .create_issue("T", "b", &[], None, None, "todo")
812 .unwrap_err();
813 let text = err.to_string();
814 assert!(text.contains("<token>"), "expected redaction, got: {text}");
815 assert!(!text.contains("lin-token"), "secret leaked: {text}");
816 }
817
818 #[test]
822 fn t036_project_reference_is_read_from_detail_and_never_sent() {
823 let list = r#"[{"identifier":"TIX-1","title":"T","state":{"name":"Todo"}}]"#;
824 let detail = r#"{"identifier":"TIX-1","title":"T","state":{"name":"Todo"},
825 "project":{"name":"One"}}"#;
826 let runner = FakeRunner::new(vec![
827 CommandOutcome::linear_list(detail),
828 CommandOutcome::linear_list(list),
829 ]);
830 let mut adapter = adapter_with(runner, None);
831 let issues = adapter.list_open().unwrap();
832 assert_eq!(
833 issues[0].projects,
834 vec![crate::sync::RemoteProject {
835 id: String::new(),
836 name: Some("One".into()),
837 }],
838 "the real query selects `project{{name}}`, so the id is empty and the name kept"
839 );
840
841 let calls_before = recorded(&adapter).len();
842 adapter.set_projects("TIX-1", &[]).unwrap();
843 assert_eq!(
844 recorded(&adapter).len(),
845 calls_before,
846 "an empty membership set must not spawn any command"
847 );
848 }
849
850 #[test]
854 fn t038_no_epic_reference_is_read_or_sent() {
855 let list = r#"[{"identifier":"TIX-1","title":"T","state":{"name":"Todo"}}]"#;
856 let detail = r#"{"identifier":"TIX-1","title":"T","state":{"name":"Todo"},
858 "epics":["e-1"]}"#;
859 let runner = FakeRunner::new(vec![
860 CommandOutcome::linear_list(detail),
861 CommandOutcome::linear_list(list),
862 ]);
863 let mut adapter = adapter_with(runner, None);
864 let issues = adapter.list_open().unwrap();
865 assert_eq!(issues[0].epics, [] as [crate::sync::RemoteEpic; 0]);
866
867 let calls_before = recorded(&adapter).len();
868 adapter.set_epics("TIX-1", &[]).unwrap();
869 assert_eq!(
870 recorded(&adapter).len(),
871 calls_before,
872 "an empty reference set must not spawn any command"
873 );
874 }
875}