Skip to content

Commit 07653f7

Browse files
authored
refactor: split status command god file into focused modules (#7551) (#8127)
src/commands/status.rs tripped both structural audit findings — 1558 lines (threshold 1500) and 41 top-level items (threshold 30). Convert it to src/commands/status/mod.rs and extract three cohesive submodules: - types.rs — CLI args and serialized output shapes - git_cache.rs — StatusGitCache plus git probing/caching helpers - context_paths.rs — registered-context detection for the default view mod.rs keeps the orchestration entry points (run, dashboard/summary builders) and the test module. Public surface (StatusArgs, run, output types) is preserved via re-exports, so external callers (command_contract descriptors) are unchanged. No behavior change; all 18 status tests pass. Every resulting file is now under both structural thresholds.
1 parent ed351c2 commit 07653f7

4 files changed

Lines changed: 722 additions & 663 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
//! Registered-context detection for the default `status` view.
2+
//!
3+
//! Determines whether the current (or git-root) directory maps to a registered
4+
//! component/project checkout, so `homeboy status` can fast-return an
5+
//! actionable "unregistered context" hint instead of scanning every configured
6+
//! component.
7+
8+
use std::fs;
9+
use std::path::{Path, PathBuf};
10+
11+
use serde_json::Value;
12+
13+
use homeboy::core::git;
14+
15+
use super::types::UnregisteredContextStatusOutput;
16+
17+
pub(super) fn unregistered_cwd_status_output() -> Option<UnregisteredContextStatusOutput> {
18+
let cwd = std::env::current_dir().ok()?;
19+
let git_root = git::get_git_root(&cwd.to_string_lossy())
20+
.ok()
21+
.map(PathBuf::from);
22+
let candidates = [Some(cwd.as_path()), git_root.as_deref()];
23+
24+
if candidates
25+
.into_iter()
26+
.flatten()
27+
.any(path_is_registered_context)
28+
{
29+
return None;
30+
}
31+
32+
let git_root_string = git_root
33+
.as_ref()
34+
.map(|path| path.to_string_lossy().to_string());
35+
let suggestion = if let Some(ref git_root) = git_root_string {
36+
format!(
37+
"Repo not attached. Prefer: `homeboy project components attach-path <project-id> {}`",
38+
git_root
39+
)
40+
} else {
41+
"Repo not attached. Prefer: `homeboy project components attach-path <project-id> <path>`"
42+
.to_string()
43+
};
44+
45+
Some(UnregisteredContextStatusOutput {
46+
command: "status",
47+
status: "unregistered_context",
48+
cwd: cwd.to_string_lossy().to_string(),
49+
git_root: git_root_string,
50+
suggestion,
51+
action: "Run `homeboy status --all` to inspect every configured component, or attach this checkout to a project/component first.",
52+
})
53+
}
54+
55+
fn path_is_registered_context(path: &Path) -> bool {
56+
registered_local_paths().into_iter().any(|registered| {
57+
path_is_at_or_inside(&registered, path) || path_is_at_or_inside(path, &registered)
58+
})
59+
}
60+
61+
fn registered_local_paths() -> Vec<PathBuf> {
62+
let Ok(home) = std::env::var("HOME") else {
63+
return Vec::new();
64+
};
65+
let config_root = PathBuf::from(home).join(".config").join("homeboy");
66+
[config_root.join("components"), config_root.join("projects")]
67+
.into_iter()
68+
.flat_map(json_files_under)
69+
.filter_map(|path| fs::read_to_string(path).ok())
70+
.filter_map(|raw| serde_json::from_str::<Value>(&raw).ok())
71+
.flat_map(|value| {
72+
let mut paths = Vec::new();
73+
collect_local_paths(&value, &mut paths);
74+
paths
75+
})
76+
.collect()
77+
}
78+
79+
fn json_files_under(root: PathBuf) -> Vec<PathBuf> {
80+
let mut files = Vec::new();
81+
collect_json_files(&root, &mut files);
82+
files
83+
}
84+
85+
fn collect_json_files(path: &Path, files: &mut Vec<PathBuf>) {
86+
if path.is_file() {
87+
if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
88+
files.push(path.to_path_buf());
89+
}
90+
return;
91+
}
92+
93+
let Ok(entries) = fs::read_dir(path) else {
94+
return;
95+
};
96+
for entry in entries.flatten() {
97+
collect_json_files(&entry.path(), files);
98+
}
99+
}
100+
101+
fn collect_local_paths(value: &Value, paths: &mut Vec<PathBuf>) {
102+
match value {
103+
Value::Object(map) => {
104+
for (key, value) in map {
105+
if matches!(key.as_str(), "local_path" | "localPath") {
106+
if let Some(path) = value.as_str().filter(|path| !path.trim().is_empty()) {
107+
paths.push(homeboy::core::expand_tilde_path(path));
108+
}
109+
}
110+
collect_local_paths(value, paths);
111+
}
112+
}
113+
Value::Array(items) => {
114+
for item in items {
115+
collect_local_paths(item, paths);
116+
}
117+
}
118+
_ => {}
119+
}
120+
}
121+
122+
fn path_is_at_or_inside(parent: &Path, path: &Path) -> bool {
123+
match (parent.canonicalize().ok(), path.canonicalize().ok()) {
124+
(Some(parent), Some(path)) => path == parent || path.starts_with(parent),
125+
_ => false,
126+
}
127+
}

src/commands/status/git_cache.rs

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
//! Git-state caching and probing for the `status` command.
2+
//!
3+
//! `StatusGitCache` memoizes per-component git work (tag fetches, upstream
4+
//! drift, release-state baselines, default-branch resolution) so a single
5+
//! status run touches each repo's git plumbing once. The free functions below
6+
//! back the cache and the merged-not-released / remote-version probes.
7+
8+
use std::collections::{HashMap, HashSet};
9+
10+
use homeboy::core::component;
11+
use homeboy::core::deploy::{self, ReleaseState};
12+
use homeboy::core::git;
13+
use homeboy::core::release::version;
14+
15+
use super::types::{UnreleasedMerge, UpstreamDrift};
16+
17+
#[derive(Default)]
18+
pub(super) struct StatusGitCache {
19+
pub(super) upstream_drift: HashMap<String, Option<UpstreamDrift>>,
20+
fetched_tags: HashSet<String>,
21+
release_states: HashMap<String, Option<ReleaseState>>,
22+
baselines: HashMap<String, Option<git::BaselineInfo>>,
23+
origin_branches: HashMap<String, Option<String>>,
24+
}
25+
26+
impl StatusGitCache {
27+
pub(super) fn fetch_origin_tags_for(&mut self, path: &str) {
28+
let cache_key = upstream_drift_cache_key(path);
29+
if self.fetched_tags.insert(cache_key) {
30+
fetch_origin_tags(path);
31+
}
32+
}
33+
34+
pub(super) fn fetch_upstream_drift_for(
35+
&mut self,
36+
component: &component::Component,
37+
) -> Option<UpstreamDrift> {
38+
let path = &component.local_path;
39+
let cache_key = component_cache_key(component);
40+
if !self.upstream_drift.contains_key(&cache_key) {
41+
self.fetch_origin_tags_for(path);
42+
self.upstream_drift
43+
.insert(cache_key.clone(), get_upstream_drift(component));
44+
}
45+
46+
let drift = self.upstream_drift.get(&cache_key)?;
47+
48+
drift.as_ref().map(|cached| {
49+
let mut drift = cached.clone();
50+
drift.component_id = component.id.clone();
51+
drift
52+
})
53+
}
54+
55+
pub(super) fn release_state_for(
56+
&mut self,
57+
component: &component::Component,
58+
) -> Option<&ReleaseState> {
59+
let cache_key = component_cache_key(component);
60+
if !self.release_states.contains_key(&cache_key) {
61+
let state = self.baseline_for(component).and_then(|baseline| {
62+
deploy::calculate_release_state_from_baseline(component, baseline)
63+
});
64+
self.release_states.insert(cache_key.clone(), state);
65+
}
66+
67+
self.release_states.get(&cache_key).and_then(Option::as_ref)
68+
}
69+
70+
fn baseline_for(&mut self, component: &component::Component) -> Option<&git::BaselineInfo> {
71+
let cache_key = component_cache_key(component);
72+
if !self.baselines.contains_key(&cache_key) {
73+
self.fetch_origin_tags_for(&component.local_path);
74+
let current_version = version::read_component_version(component)
75+
.ok()
76+
.map(|info| info.version);
77+
let tag_prefix = homeboy::core::release::component_tag_prefix(component)
78+
.ok()
79+
.flatten();
80+
let baseline = git::detect_baseline_with_version_and_tag_prefix_from_fetched_tags(
81+
&component.local_path,
82+
current_version.as_deref(),
83+
tag_prefix.as_deref(),
84+
)
85+
.ok();
86+
self.baselines.insert(cache_key.clone(), baseline);
87+
}
88+
89+
self.baselines.get(&cache_key).and_then(Option::as_ref)
90+
}
91+
92+
fn default_origin_branch_for(&mut self, path: &str) -> Option<&str> {
93+
let cache_key = upstream_drift_cache_key(path);
94+
if !self.origin_branches.contains_key(&cache_key) {
95+
self.origin_branches
96+
.insert(cache_key.clone(), default_origin_branch(path));
97+
}
98+
99+
self.origin_branches
100+
.get(&cache_key)
101+
.and_then(Option::as_deref)
102+
}
103+
104+
pub(super) fn detect_unreleased_merges_for(
105+
&mut self,
106+
comp: &component::Component,
107+
) -> Option<UnreleasedMerge> {
108+
let path = &comp.local_path;
109+
110+
let origin_branch = self.default_origin_branch_for(path)?.to_string();
111+
let baseline = self.baseline_for(comp)?;
112+
let baseline_ref = baseline.reference.as_deref()?;
113+
114+
let range = format!("{}..{}", baseline_ref, origin_branch);
115+
let count_output = homeboy::core::engine::command::run_in_optional(
116+
path,
117+
"git",
118+
&["rev-list", "--count", "--no-merges", &range],
119+
)?;
120+
121+
let commits_since_tag: u32 = count_output.trim().parse().ok()?;
122+
if commits_since_tag == 0 {
123+
return None;
124+
}
125+
126+
Some(UnreleasedMerge {
127+
component_id: comp.id.clone(),
128+
latest_tag: baseline.latest_tag.clone(),
129+
commits_since_tag,
130+
})
131+
}
132+
}
133+
134+
pub(super) fn upstream_drift_cache_key(path: &str) -> String {
135+
git::get_git_root(path).unwrap_or_else(|_| path.to_string())
136+
}
137+
138+
pub(super) fn component_cache_key(component: &component::Component) -> String {
139+
format!("{}\0{}", component.id, component.local_path)
140+
}
141+
142+
fn fetch_origin_tags(path: &str) {
143+
// Best-effort fetch — silently proceeds if no remote or network issue.
144+
let _ = homeboy::core::engine::command::run_in_optional(
145+
path,
146+
"git",
147+
&["fetch", "--tags", "--quiet"],
148+
);
149+
}
150+
151+
fn get_upstream_drift(component: &component::Component) -> Option<UpstreamDrift> {
152+
let path = &component.local_path;
153+
let snapshot = git::get_repo_snapshot(path).ok()?;
154+
155+
// After fetching tags, find the latest tag across ALL refs (not just HEAD).
156+
// `git describe --tags --abbrev=0` only returns tags reachable from HEAD,
157+
// which misses newer tags when the local checkout is behind.
158+
let tag_prefix = homeboy::core::release::component_tag_prefix(component)
159+
.ok()
160+
.flatten();
161+
let latest_origin_tag = git::get_latest_tag_any_with_prefix(path, tag_prefix.as_deref())
162+
.ok()
163+
.flatten();
164+
165+
Some(UpstreamDrift {
166+
component_id: String::new(), // caller sets component_id after
167+
ahead: snapshot.ahead,
168+
behind: snapshot.behind,
169+
latest_origin_tag,
170+
})
171+
}
172+
173+
/// Log merged-but-unreleased components to stderr for human-readable output.
174+
///
175+
/// Mirrors the dashboard table's terminal-only behavior so JSON consumers are
176+
/// unaffected. Keeps the merged-not-released signal visible in `homeboy status`
177+
/// without a project argument (issue #4996).
178+
pub(super) fn log_unreleased_merges(merges: &[UnreleasedMerge]) {
179+
if merges.is_empty() || !std::io::IsTerminal::is_terminal(&std::io::stderr()) {
180+
return;
181+
}
182+
183+
eprintln!(
184+
"⚠️ {} component(s) carry merged-but-unreleased work (merged to origin, NOT in any release — code is not on prod yet):",
185+
merges.len()
186+
);
187+
for merge in merges {
188+
let tag = merge.latest_tag.as_deref().unwrap_or("(no tag)");
189+
eprintln!(
190+
" {} — {} commit(s) past {}",
191+
merge.component_id, merge.commits_since_tag, tag
192+
);
193+
}
194+
eprintln!(" Cut a release, then `homeboy status <project>` to confirm installed-vs-tag.");
195+
}
196+
197+
/// Resolve the default origin branch ref for a checkout.
198+
///
199+
/// Precedence matches the deploy planner: `origin/HEAD` symbolic ref first, then
200+
/// the conventional `origin/main` / `origin/trunk` / `origin/master` fallbacks.
201+
pub(super) fn default_origin_branch(path: &str) -> Option<String> {
202+
if let Some(symbolic) = homeboy::core::engine::command::run_in_optional(
203+
path,
204+
"git",
205+
&[
206+
"symbolic-ref",
207+
"--quiet",
208+
"--short",
209+
"refs/remotes/origin/HEAD",
210+
],
211+
) {
212+
let symbolic = symbolic.trim();
213+
if !symbolic.is_empty() {
214+
return Some(symbolic.to_string());
215+
}
216+
}
217+
218+
["origin/main", "origin/trunk", "origin/master"]
219+
.iter()
220+
.find(|branch| {
221+
homeboy::core::engine::command::run_in_optional(
222+
path,
223+
"git",
224+
&["rev-parse", "--verify", "--quiet", branch],
225+
)
226+
.is_some()
227+
})
228+
.map(|branch| (*branch).to_string())
229+
}
230+
231+
/// Fetch remote (deployed) versions for all components in a project.
232+
///
233+
/// Uses deploy check mode internally, which handles SSH resolution.
234+
/// Returns empty map on failure (e.g., no server configured, SSH unavailable).
235+
pub(super) fn fetch_project_remote_versions(
236+
project_id: &str,
237+
components: &[component::Component],
238+
) -> deploy::RemoteVersionProbeResult {
239+
match deploy::fetch_project_remote_versions(project_id, components) {
240+
Ok(result) => result,
241+
Err(_) => {
242+
homeboy::log_status!(
243+
"status",
244+
"Warning: could not fetch remote versions for project '{}' — showing local data only",
245+
project_id
246+
);
247+
deploy::RemoteVersionProbeResult::default()
248+
}
249+
}
250+
}

0 commit comments

Comments
 (0)