@lamentis/naome 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/Cargo.lock +2 -2
  2. package/crates/naome-cli/Cargo.toml +1 -1
  3. package/crates/naome-cli/src/main.rs +9 -0
  4. package/crates/naome-cli/src/task_commands/common.rs +32 -0
  5. package/crates/naome-cli/src/task_commands/readiness.rs +40 -0
  6. package/crates/naome-cli/src/task_commands/record.rs +134 -0
  7. package/crates/naome-cli/src/task_commands/repair.rs +30 -0
  8. package/crates/naome-cli/src/task_commands/scope_request.rs +24 -0
  9. package/crates/naome-cli/src/task_commands/timeline.rs +71 -0
  10. package/crates/naome-cli/src/task_commands.rs +69 -1
  11. package/crates/naome-cli/tests/task_cli.rs +58 -0
  12. package/crates/naome-cli/tests/task_cli_agent_controls.rs +217 -0
  13. package/crates/naome-cli/tests/task_cli_control.rs +126 -0
  14. package/crates/naome-cli/tests/task_cli_support/mod.rs +150 -0
  15. package/crates/naome-core/Cargo.toml +1 -1
  16. package/crates/naome-core/src/lib.rs +7 -2
  17. package/crates/naome-core/src/task_state/mod.rs +10 -0
  18. package/crates/naome-core/src/task_state/status/agent_model.rs +76 -0
  19. package/crates/naome-core/src/task_state/status/control/action.rs +87 -0
  20. package/crates/naome-core/src/task_state/status/control/exit_code.rs +32 -0
  21. package/crates/naome-core/src/task_state/status/control/loop_state.rs +70 -0
  22. package/crates/naome-core/src/task_state/status/control/policy.rs +31 -0
  23. package/crates/naome-core/src/task_state/status/control/proof_recording.rs +25 -0
  24. package/crates/naome-core/src/task_state/status/control/recovery.rs +19 -0
  25. package/crates/naome-core/src/task_state/status/control/repair.rs +125 -0
  26. package/crates/naome-core/src/task_state/status/control/shared.rs +25 -0
  27. package/crates/naome-core/src/task_state/status/control.rs +16 -0
  28. package/crates/naome-core/src/task_state/status/git.rs +133 -0
  29. package/crates/naome-core/src/task_state/status/model.rs +150 -0
  30. package/crates/naome-core/src/task_state/status/proof.rs +167 -0
  31. package/crates/naome-core/src/task_state/status/proof_read.rs +150 -0
  32. package/crates/naome-core/src/task_state/status/report.rs +148 -0
  33. package/crates/naome-core/src/task_state/status/report_context.rs +126 -0
  34. package/crates/naome-core/src/task_state/status/report_support.rs +117 -0
  35. package/crates/naome-core/src/task_state/status/scope.rs +111 -0
  36. package/crates/naome-core/src/task_state/status/transition.rs +73 -0
  37. package/crates/naome-core/src/task_state/status.rs +23 -0
  38. package/crates/naome-core/src/task_state/status_output.rs +103 -0
  39. package/crates/naome-core/tests/task_state_support/mod.rs +15 -1
  40. package/crates/naome-core/tests/task_state_support/states.rs +4 -0
  41. package/crates/naome-core/tests/task_status.rs +301 -0
  42. package/crates/naome-core/tests/task_status_git.rs +141 -0
  43. package/native/darwin-arm64/naome +0 -0
  44. package/native/linux-x64/naome +0 -0
  45. package/package.json +1 -1
@@ -0,0 +1,87 @@
1
+ use super::super::agent_model::NextActionV2;
2
+ use super::super::model::{
3
+ TaskProofStatus, TaskRecommendedCommand, TaskScopeStatus, TaskStatusFinding,
4
+ };
5
+ use super::shared::{action_check_ids, has_finding, has_git_recovery};
6
+
7
+ pub(in crate::task_state::status) fn next_action_v2(
8
+ state: &str,
9
+ proof: &TaskProofStatus,
10
+ findings: &[TaskStatusFinding],
11
+ commands: &[TaskRecommendedCommand],
12
+ scope: &TaskScopeStatus,
13
+ ) -> NextActionV2 {
14
+ let (action_type, reason, paths, safe) = prioritized_action(state, proof, findings, scope);
15
+ NextActionV2 {
16
+ action_type: action_type.to_string(),
17
+ reason,
18
+ check_ids: action_check_ids(proof),
19
+ commands: commands
20
+ .iter()
21
+ .map(|command| command.command.clone())
22
+ .collect(),
23
+ paths,
24
+ safe_to_execute: safe,
25
+ requires_user_approval: !safe,
26
+ }
27
+ }
28
+
29
+ fn prioritized_action(
30
+ state: &str,
31
+ proof: &TaskProofStatus,
32
+ findings: &[TaskStatusFinding],
33
+ scope: &TaskScopeStatus,
34
+ ) -> (&'static str, String, Vec<String>, bool) {
35
+ if has_finding(findings, "task.state.conflict_markers") {
36
+ return (
37
+ "blocked",
38
+ "Task-state conflict markers must be resolved.".to_string(),
39
+ vec![],
40
+ false,
41
+ );
42
+ }
43
+ if has_git_recovery(findings) {
44
+ return (
45
+ "recover_git_state",
46
+ "Git recovery is required before task work can continue.".to_string(),
47
+ vec![],
48
+ false,
49
+ );
50
+ }
51
+ if has_finding(findings, "task.scope.out_of_scope_change") {
52
+ return (
53
+ "repair_scope",
54
+ "Out-of-scope changes must be removed or admitted.".to_string(),
55
+ scope.out_of_scope_changed_paths.clone(),
56
+ true,
57
+ );
58
+ }
59
+ if !proof.missing_checks.is_empty() || !proof.stale_checks.is_empty() {
60
+ return (
61
+ "rerun_checks",
62
+ "Required proof is missing or stale.".to_string(),
63
+ scope.in_scope_changed_paths.clone(),
64
+ true,
65
+ );
66
+ }
67
+ match state {
68
+ "idle" | "missing" => (
69
+ "create_task",
70
+ "No active task is in progress.".to_string(),
71
+ vec![],
72
+ true,
73
+ ),
74
+ "complete" => (
75
+ "none",
76
+ "Task is complete and proof is current.".to_string(),
77
+ vec![],
78
+ true,
79
+ ),
80
+ _ => (
81
+ "continue_editing",
82
+ "Active task is healthy.".to_string(),
83
+ scope.in_scope_changed_paths.clone(),
84
+ true,
85
+ ),
86
+ }
87
+ }
@@ -0,0 +1,32 @@
1
+ use super::super::model::{TaskProofStatus, TaskStatusFinding};
2
+ use super::shared::has_finding;
3
+
4
+ pub fn task_status_exit_code(findings: &[TaskStatusFinding], proof: &TaskProofStatus) -> i32 {
5
+ if has_finding(findings, "task.state.conflict_markers")
6
+ || has_finding(findings, "task.state.invalid_json")
7
+ || has_finding(findings, "task.state.active_task_missing")
8
+ {
9
+ 40
10
+ } else if findings.iter().any(|finding| {
11
+ matches!(
12
+ finding.id.as_str(),
13
+ "task.git.operation_in_progress"
14
+ | "task.git.admission_head_missing"
15
+ | "task.git.admission_head_not_reachable"
16
+ )
17
+ }) {
18
+ 30
19
+ } else if has_finding(findings, "task.scope.out_of_scope_change") {
20
+ 20
21
+ } else if has_finding(findings, "task.state.no_active_task_with_diff") {
22
+ 50
23
+ } else if has_finding(findings, "task.state.completed_task_has_diff") {
24
+ 60
25
+ } else if !proof.missing_checks.is_empty() {
26
+ 10
27
+ } else if !proof.stale_checks.is_empty() {
28
+ 11
29
+ } else {
30
+ 0
31
+ }
32
+ }
@@ -0,0 +1,70 @@
1
+ use super::super::agent_model::AgentLoop;
2
+ use super::super::model::{TaskProofStatus, TaskScopeStatus, TaskStatusFinding};
3
+ use super::shared::{action_check_ids, has_finding, has_git_recovery};
4
+
5
+ pub(in crate::task_state::status) fn agent_loop(
6
+ state: &str,
7
+ proof: &TaskProofStatus,
8
+ findings: &[TaskStatusFinding],
9
+ scope: &TaskScopeStatus,
10
+ ) -> AgentLoop {
11
+ let loop_state = agent_loop_state(state, proof, findings, scope);
12
+ AgentLoop {
13
+ can_continue_editing: matches!(loop_state.as_str(), "healthy" | "ready_to_commit"),
14
+ can_run_checks: !matches!(
15
+ loop_state.as_str(),
16
+ "blocked_by_scope_drift" | "blocked_by_git_state" | "blocked_by_task_state"
17
+ ),
18
+ can_record_proof: matches!(
19
+ loop_state.as_str(),
20
+ "blocked_by_missing_proof" | "blocked_by_stale_proof" | "ready_to_commit"
21
+ ),
22
+ can_commit: loop_state == "ready_to_commit",
23
+ must_do_next: must_do_next(&loop_state, proof),
24
+ must_not_do: must_not_do(&loop_state),
25
+ state: loop_state,
26
+ }
27
+ }
28
+
29
+ fn agent_loop_state(
30
+ state: &str,
31
+ proof: &TaskProofStatus,
32
+ findings: &[TaskStatusFinding],
33
+ scope: &TaskScopeStatus,
34
+ ) -> String {
35
+ if has_finding(findings, "task.state.conflict_markers") {
36
+ "blocked_by_task_state"
37
+ } else if has_git_recovery(findings) {
38
+ "blocked_by_git_state"
39
+ } else if has_finding(findings, "task.scope.out_of_scope_change") {
40
+ "blocked_by_scope_drift"
41
+ } else if has_finding(findings, "task.state.no_active_task_with_diff") {
42
+ "blocked_by_task_state"
43
+ } else if !proof.missing_checks.is_empty() {
44
+ "blocked_by_missing_proof"
45
+ } else if !proof.stale_checks.is_empty() {
46
+ "blocked_by_stale_proof"
47
+ } else if matches!(state, "idle" | "missing") {
48
+ "ready_for_new_task"
49
+ } else if state == "complete" || !scope.in_scope_changed_paths.is_empty() {
50
+ "ready_to_commit"
51
+ } else {
52
+ "healthy"
53
+ }
54
+ .to_string()
55
+ }
56
+
57
+ fn must_do_next(state: &str, proof: &TaskProofStatus) -> Vec<String> {
58
+ match state {
59
+ "blocked_by_missing_proof" | "blocked_by_stale_proof" => action_check_ids(proof),
60
+ value => vec![value.to_string()],
61
+ }
62
+ }
63
+
64
+ fn must_not_do(state: &str) -> Vec<String> {
65
+ let mut items = vec!["Do not bypass the NAOME commit gate.".to_string()];
66
+ if state != "ready_to_commit" {
67
+ items.push("Do not commit until task can-transition allows completion.".to_string());
68
+ }
69
+ items
70
+ }
@@ -0,0 +1,31 @@
1
+ use super::super::agent_model::PolicyHints;
2
+ use super::super::model::{TaskProofStatus, TaskScopeStatus, TaskStatusFinding};
3
+ use super::shared::action_check_ids;
4
+
5
+ pub(in crate::task_state::status) fn policy_hints(
6
+ scope: &TaskScopeStatus,
7
+ proof: &TaskProofStatus,
8
+ findings: &[TaskStatusFinding],
9
+ ) -> PolicyHints {
10
+ let may_edit = if scope.in_scope_changed_paths.is_empty() {
11
+ scope.allowed_paths.clone()
12
+ } else {
13
+ scope.in_scope_changed_paths.clone()
14
+ };
15
+ let mut forbidden_actions = vec![
16
+ "Do not bypass the NAOME commit gate.".to_string(),
17
+ "Do not commit before can-transition or can-commit allows it.".to_string(),
18
+ ];
19
+ if findings
20
+ .iter()
21
+ .any(|finding| finding.id == "task.scope.out_of_scope_change")
22
+ {
23
+ forbidden_actions.push("Do not keep out-of-scope changes in the task diff.".to_string());
24
+ }
25
+ PolicyHints {
26
+ may_edit,
27
+ must_inspect_before_edit: scope.out_of_scope_changed_paths.clone(),
28
+ must_run_after_edit: action_check_ids(proof),
29
+ forbidden_actions,
30
+ }
31
+ }
@@ -0,0 +1,25 @@
1
+ use super::super::agent_model::{ProofRecording, ProofRecordingAfterSuccess};
2
+ use super::super::model::{TaskProofStatus, TaskScopeStatus};
3
+ use super::shared::action_check_ids;
4
+
5
+ pub(in crate::task_state::status) fn proof_recording(
6
+ task_id: Option<&str>,
7
+ scope: &TaskScopeStatus,
8
+ proof: &TaskProofStatus,
9
+ ) -> ProofRecording {
10
+ let checks_to_record = action_check_ids(proof);
11
+ let proof_batch_id = format!(
12
+ "{}-proof",
13
+ task_id.unwrap_or("current-task").replace('_', "-")
14
+ );
15
+ ProofRecording {
16
+ path_set_id: "current-task-diff".to_string(),
17
+ paths: scope.in_scope_changed_paths.clone(),
18
+ proof_batch_id,
19
+ checks_to_record: checks_to_record.clone(),
20
+ after_success: ProofRecordingAfterSuccess {
21
+ record_proof: !checks_to_record.is_empty(),
22
+ instructions: "After each command exits 0, record one proof batch using pathSetId current-task-diff and the listed in-scope paths.".to_string(),
23
+ },
24
+ }
25
+ }
@@ -0,0 +1,19 @@
1
+ use super::super::agent_model::RecoveryGuidance;
2
+ use super::super::model::TaskStatusFinding;
3
+
4
+ pub(in crate::task_state::status) fn recovery_guidance(
5
+ findings: &[TaskStatusFinding],
6
+ ) -> Vec<RecoveryGuidance> {
7
+ findings
8
+ .iter()
9
+ .filter(|finding| {
10
+ finding.id.starts_with("task.git.") || finding.id == "task.state.conflict_markers"
11
+ })
12
+ .map(|finding| RecoveryGuidance {
13
+ id: finding.id.clone(),
14
+ reason: finding.message.clone(),
15
+ safe_next_action: finding.suggested_fix.clone(),
16
+ requires_user_approval: true,
17
+ })
18
+ .collect()
19
+ }
@@ -0,0 +1,125 @@
1
+ use super::super::agent_model::RepairPlanItem;
2
+ use super::super::model::{
3
+ TaskProofStatus, TaskRecommendedCommand, TaskScopeStatus, TaskStatusFinding,
4
+ };
5
+ use super::shared::action_check_ids;
6
+
7
+ pub(in crate::task_state::status) fn repair_plan(
8
+ proof: &TaskProofStatus,
9
+ findings: &[TaskStatusFinding],
10
+ commands: &[TaskRecommendedCommand],
11
+ scope: &TaskScopeStatus,
12
+ ) -> Vec<RepairPlanItem> {
13
+ let mut items = Vec::new();
14
+ items.extend(scope_repairs(findings));
15
+ items.extend(git_repairs(findings));
16
+ items.extend(check_repairs(commands));
17
+ if !proof.missing_checks.is_empty() || !proof.stale_checks.is_empty() {
18
+ items.push(RepairPlanItem {
19
+ id: "record_current_task_proof".to_string(),
20
+ kind: "record_proof".to_string(),
21
+ reason: "Record successful missing or stale checks against the current task diff."
22
+ .to_string(),
23
+ paths: scope.in_scope_changed_paths.clone(),
24
+ check_ids: action_check_ids(proof),
25
+ commands: Vec::new(),
26
+ cwd: None,
27
+ safe_to_execute: true,
28
+ requires_user_approval: false,
29
+ });
30
+ }
31
+ items
32
+ }
33
+
34
+ fn check_repairs(commands: &[TaskRecommendedCommand]) -> Vec<RepairPlanItem> {
35
+ commands
36
+ .iter()
37
+ .map(|command| RepairPlanItem {
38
+ id: format!("rerun_{}", command.check_id),
39
+ kind: "rerun_check".to_string(),
40
+ reason: command.reason.clone(),
41
+ paths: Vec::new(),
42
+ check_ids: vec![command.check_id.clone()],
43
+ commands: vec![command.command.clone()],
44
+ cwd: Some(command.cwd.clone()),
45
+ safe_to_execute: true,
46
+ requires_user_approval: false,
47
+ })
48
+ .collect()
49
+ }
50
+
51
+ fn scope_repairs(findings: &[TaskStatusFinding]) -> Vec<RepairPlanItem> {
52
+ findings
53
+ .iter()
54
+ .filter(|finding| finding.id == "task.scope.out_of_scope_change")
55
+ .filter_map(|finding| {
56
+ let path = finding.path.clone()?;
57
+ Some(RepairPlanItem {
58
+ id: format!("remove_out_of_scope_change_{}", repair_path_slug(&path)),
59
+ kind: "git_revert_path".to_string(),
60
+ reason: finding.message.clone(),
61
+ paths: vec![path],
62
+ check_ids: Vec::new(),
63
+ commands: Vec::new(),
64
+ cwd: None,
65
+ safe_to_execute: true,
66
+ requires_user_approval: false,
67
+ })
68
+ })
69
+ .collect()
70
+ }
71
+
72
+ fn git_repairs(findings: &[TaskStatusFinding]) -> Vec<RepairPlanItem> {
73
+ findings
74
+ .iter()
75
+ .filter(|finding| {
76
+ matches!(
77
+ finding.id.as_str(),
78
+ "task.git.operation_in_progress"
79
+ | "task.git.admission_head_missing"
80
+ | "task.git.admission_head_not_reachable"
81
+ )
82
+ })
83
+ .map(|finding| RepairPlanItem {
84
+ id: if finding.id == "task.git.operation_in_progress" {
85
+ "finish_git_operation"
86
+ } else {
87
+ "restart_task"
88
+ }
89
+ .to_string(),
90
+ kind: if finding.id == "task.git.operation_in_progress" {
91
+ "finish_git_operation"
92
+ } else {
93
+ "restart_task"
94
+ }
95
+ .to_string(),
96
+ reason: finding.message.clone(),
97
+ paths: Vec::new(),
98
+ check_ids: Vec::new(),
99
+ commands: Vec::new(),
100
+ cwd: None,
101
+ safe_to_execute: false,
102
+ requires_user_approval: true,
103
+ })
104
+ .collect()
105
+ }
106
+
107
+ fn repair_path_slug(path: &str) -> String {
108
+ let slug = path
109
+ .chars()
110
+ .map(|character| {
111
+ if character.is_ascii_alphanumeric() {
112
+ character.to_ascii_lowercase()
113
+ } else {
114
+ '_'
115
+ }
116
+ })
117
+ .collect::<String>()
118
+ .trim_matches('_')
119
+ .to_string();
120
+ if slug.is_empty() {
121
+ "path".to_string()
122
+ } else {
123
+ slug
124
+ }
125
+ }
@@ -0,0 +1,25 @@
1
+ use super::super::model::{TaskProofStatus, TaskStatusFinding};
2
+
3
+ pub(super) fn action_check_ids(proof: &TaskProofStatus) -> Vec<String> {
4
+ proof
5
+ .missing_checks
6
+ .iter()
7
+ .chain(proof.stale_checks.iter())
8
+ .cloned()
9
+ .collect()
10
+ }
11
+
12
+ pub(super) fn has_finding(findings: &[TaskStatusFinding], id: &str) -> bool {
13
+ findings.iter().any(|finding| finding.id == id)
14
+ }
15
+
16
+ pub(super) fn has_git_recovery(findings: &[TaskStatusFinding]) -> bool {
17
+ findings.iter().any(|finding| {
18
+ matches!(
19
+ finding.id.as_str(),
20
+ "task.git.operation_in_progress"
21
+ | "task.git.admission_head_missing"
22
+ | "task.git.admission_head_not_reachable"
23
+ )
24
+ })
25
+ }
@@ -0,0 +1,16 @@
1
+ mod action;
2
+ mod exit_code;
3
+ mod loop_state;
4
+ mod policy;
5
+ mod proof_recording;
6
+ mod recovery;
7
+ mod repair;
8
+ mod shared;
9
+
10
+ pub(in crate::task_state::status) use action::next_action_v2;
11
+ pub use exit_code::task_status_exit_code;
12
+ pub(in crate::task_state::status) use loop_state::agent_loop;
13
+ pub(in crate::task_state::status) use policy::policy_hints;
14
+ pub(in crate::task_state::status) use proof_recording::proof_recording;
15
+ pub(in crate::task_state::status) use recovery::recovery_guidance;
16
+ pub(in crate::task_state::status) use repair::repair_plan;
@@ -0,0 +1,133 @@
1
+ use std::path::Path;
2
+ use std::process::Command;
3
+
4
+ use crate::models::NaomeError;
5
+
6
+ use super::model::{finding, TaskGitStatus, TaskStatusFinding};
7
+ use crate::task_state::git_io::{command_output, git_commit_exists, read_git_head};
8
+
9
+ pub(super) fn git_status(
10
+ root: &Path,
11
+ admission_head: Option<String>,
12
+ findings: &mut Vec<TaskStatusFinding>,
13
+ ) -> Result<TaskGitStatus, NaomeError> {
14
+ let head = read_git_head(root)?;
15
+ let mut admission_head_reachable = false;
16
+ if let Some(admission_head) = admission_head.as_deref() {
17
+ if !git_commit_exists(root, admission_head)? {
18
+ findings.push(finding(
19
+ "task.git.admission_head_missing",
20
+ "error",
21
+ format!("Admission git head does not exist: {admission_head}."),
22
+ None,
23
+ "Recover the task-state from a reachable baseline or restart the task after sync.",
24
+ "Do not continue normal task work until the admission head is reachable.",
25
+ ));
26
+ } else if !is_ancestor(root, admission_head, "HEAD")? {
27
+ findings.push(finding(
28
+ "task.git.admission_head_not_reachable",
29
+ "error",
30
+ "Admission git head is not an ancestor of the current HEAD.",
31
+ None,
32
+ "Rebase or recreate the task-state against the current branch before continuing.",
33
+ "Do not commit task work against a branch that lost its admission baseline.",
34
+ ));
35
+ } else {
36
+ admission_head_reachable = true;
37
+ }
38
+ }
39
+
40
+ let (upstream, ahead, behind) = branch_divergence(root)?;
41
+ if upstream.is_some() && ahead > 0 && behind > 0 {
42
+ findings.push(finding(
43
+ "task.git.branch_diverged",
44
+ "warning",
45
+ format!("Current branch diverged from upstream ({ahead} ahead, {behind} behind)."),
46
+ None,
47
+ "Pull with rebase or coordinate the branch before recording final task proof.",
48
+ "Do not ignore branch divergence when interpreting task proof.",
49
+ ));
50
+ }
51
+
52
+ let operation_in_progress = operation_in_progress(root)?;
53
+ if let Some(operation) = &operation_in_progress {
54
+ findings.push(finding(
55
+ "task.git.operation_in_progress",
56
+ "error",
57
+ format!("Git operation in progress: {operation}."),
58
+ None,
59
+ "Finish or abort the git operation, then rerun naome task status.",
60
+ "Do not commit or mutate task-state while a git operation is unresolved.",
61
+ ));
62
+ }
63
+
64
+ Ok(TaskGitStatus {
65
+ head,
66
+ admission_head,
67
+ admission_head_reachable,
68
+ upstream,
69
+ ahead,
70
+ behind,
71
+ operation_in_progress,
72
+ })
73
+ }
74
+
75
+ fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> Result<bool, NaomeError> {
76
+ Ok(Command::new("git")
77
+ .args(["merge-base", "--is-ancestor", ancestor, descendant])
78
+ .current_dir(root)
79
+ .status()?
80
+ .success())
81
+ }
82
+
83
+ fn branch_divergence(root: &Path) -> Result<(Option<String>, usize, usize), NaomeError> {
84
+ let upstream = Command::new("git")
85
+ .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
86
+ .current_dir(root)
87
+ .output()?;
88
+ if !upstream.status.success() {
89
+ return Ok((None, 0, 0));
90
+ }
91
+ let upstream_name = command_output(&upstream);
92
+ let counts = Command::new("git")
93
+ .args(["rev-list", "--left-right", "--count", "HEAD...@{u}"])
94
+ .current_dir(root)
95
+ .output()?;
96
+ if !counts.status.success() {
97
+ return Ok((Some(upstream_name), 0, 0));
98
+ }
99
+ let text = String::from_utf8_lossy(&counts.stdout);
100
+ let mut parts = text.split_whitespace();
101
+ let ahead = parts
102
+ .next()
103
+ .and_then(|value| value.parse::<usize>().ok())
104
+ .unwrap_or(0);
105
+ let behind = parts
106
+ .next()
107
+ .and_then(|value| value.parse::<usize>().ok())
108
+ .unwrap_or(0);
109
+ Ok((Some(upstream_name), ahead, behind))
110
+ }
111
+
112
+ fn operation_in_progress(root: &Path) -> Result<Option<String>, NaomeError> {
113
+ for (name, marker) in [
114
+ ("merge", "MERGE_HEAD"),
115
+ ("cherry-pick", "CHERRY_PICK_HEAD"),
116
+ ("revert", "REVERT_HEAD"),
117
+ ("rebase", "rebase-merge"),
118
+ ("rebase", "rebase-apply"),
119
+ ] {
120
+ let output = Command::new("git")
121
+ .args(["rev-parse", "--git-path", marker])
122
+ .current_dir(root)
123
+ .output()?;
124
+ if !output.status.success() {
125
+ continue;
126
+ }
127
+ let path = root.join(String::from_utf8_lossy(&output.stdout).trim());
128
+ if path.exists() {
129
+ return Ok(Some(name.to_string()));
130
+ }
131
+ }
132
+ Ok(None)
133
+ }