huskies: merge 758
This commit is contained in:
@@ -157,206 +157,5 @@ pub fn start_watcher(git_root: PathBuf, event_tx: broadcast::Sender<WatcherEvent
|
||||
});
|
||||
}
|
||||
|
||||
// ── Test-only helpers (legacy; retained for the test suite) ───────────────
|
||||
|
||||
/// Return the pipeline stage name for a path if it is a `.md` file living
|
||||
/// directly inside one of the known work subdirectories, otherwise `None`.
|
||||
///
|
||||
/// Explicitly returns `None` for any path under `.huskies/worktrees/` so
|
||||
/// that code changes made by agents in their isolated worktrees are never
|
||||
/// auto-committed to master by the watcher.
|
||||
///
|
||||
/// Retained for tests; no longer called in production (CRDT drives events).
|
||||
#[cfg(test)]
|
||||
fn stage_for_path(path: &Path) -> Option<String> {
|
||||
// Reject any path that passes through the worktrees directory.
|
||||
if path.components().any(|c| c.as_os_str() == "worktrees") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if path.extension().is_none_or(|e| e != "md") {
|
||||
return None;
|
||||
}
|
||||
let stage = path
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())?;
|
||||
matches!(
|
||||
stage,
|
||||
"1_backlog" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived"
|
||||
)
|
||||
.then(|| stage.to_string())
|
||||
}
|
||||
|
||||
/// Stage all changes in the work directory and commit with the given message.
|
||||
///
|
||||
/// Uses `git add -A .huskies/work/` to catch both additions and deletions in
|
||||
/// a single commit. Returns `Ok(true)` if a commit was made, `Ok(false)` if
|
||||
/// there was nothing to commit, and `Err` for unexpected failures.
|
||||
///
|
||||
/// Retained for tests; no longer called in production (CRDT drives events).
|
||||
#[cfg(test)]
|
||||
fn git_add_work_and_commit(git_root: &Path, message: &str) -> Result<bool, String> {
|
||||
let work_rel = PathBuf::from(".huskies").join("work");
|
||||
|
||||
let add_out = std::process::Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
.arg(&work_rel)
|
||||
.current_dir(git_root)
|
||||
.output()
|
||||
.map_err(|e| format!("git add: {e}"))?;
|
||||
if !add_out.status.success() {
|
||||
return Err(format!(
|
||||
"git add failed: {}",
|
||||
String::from_utf8_lossy(&add_out.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let commit_out = std::process::Command::new("git")
|
||||
.args(["commit", "-m", message])
|
||||
.current_dir(git_root)
|
||||
.output()
|
||||
.map_err(|e| format!("git commit: {e}"))?;
|
||||
|
||||
if commit_out.status.success() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&commit_out.stderr);
|
||||
let stdout = String::from_utf8_lossy(&commit_out.stdout);
|
||||
if stdout.contains("nothing to commit") || stderr.contains("nothing to commit") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Err(format!("git commit failed: {stderr}"))
|
||||
}
|
||||
|
||||
/// Stages that represent meaningful git checkpoints (creation and archival).
|
||||
/// Intermediate stages (current, qa, merge, done) are transient pipeline state
|
||||
/// that don't need to be committed.
|
||||
///
|
||||
/// Retained for tests; no longer called in production (CRDT drives events).
|
||||
#[cfg(test)]
|
||||
const COMMIT_WORTHY_STAGES: &[&str] = &["1_backlog", "5_done", "6_archived"];
|
||||
|
||||
/// Return `true` if changes in `stage` should be committed to git.
|
||||
///
|
||||
/// Retained for tests; no longer called in production (CRDT drives events).
|
||||
#[cfg(test)]
|
||||
fn should_commit_stage(stage: &str) -> bool {
|
||||
COMMIT_WORTHY_STAGES.contains(&stage)
|
||||
}
|
||||
|
||||
/// Process a batch of pending (path → stage) entries: commit and broadcast.
|
||||
///
|
||||
/// Only files that still exist on disk are used to derive the commit message
|
||||
/// (they represent the destination of a move or a new file). Deletions are
|
||||
/// captured by `git add -A .huskies/work/` automatically.
|
||||
///
|
||||
/// Only terminal stages (`1_backlog` and `6_archived`) trigger git commits.
|
||||
/// All stages broadcast a [`WatcherEvent`] so the frontend stays in sync.
|
||||
///
|
||||
/// Retained for tests; no longer called in production (CRDT drives events).
|
||||
#[cfg(test)]
|
||||
fn flush_pending(
|
||||
pending: &std::collections::HashMap<PathBuf, String>,
|
||||
git_root: &Path,
|
||||
event_tx: &broadcast::Sender<WatcherEvent>,
|
||||
) {
|
||||
use crate::io::story_metadata::clear_front_matter_field;
|
||||
|
||||
// Separate into files that exist (additions) vs gone (deletions).
|
||||
let mut additions: Vec<(&PathBuf, &str)> = Vec::new();
|
||||
for (path, stage) in pending {
|
||||
if path.exists() {
|
||||
additions.push((path, stage.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the commit message from the first addition (the meaningful side of a move).
|
||||
// If there are only deletions, use a generic message.
|
||||
let (action, item_id, commit_msg) = if let Some((path, stage)) = additions.first() {
|
||||
let item = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
if let Some((act, msg)) = stage_metadata(stage, item) {
|
||||
(act, item.to_string(), msg)
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Only deletions — pick any pending path for the item name.
|
||||
let Some((path, _)) = pending.iter().next() else {
|
||||
return;
|
||||
};
|
||||
let item = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
(
|
||||
"remove",
|
||||
item.to_string(),
|
||||
format!("huskies: remove {item}"),
|
||||
)
|
||||
};
|
||||
|
||||
// Strip stale merge_failure front matter from any story that has left 4_merge/.
|
||||
for (path, stage) in &additions {
|
||||
if *stage != "4_merge"
|
||||
&& let Err(e) = clear_front_matter_field(path, "merge_failure")
|
||||
{
|
||||
slog!(
|
||||
"[watcher] Warning: could not clear merge_failure from {}: {e}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only commit for terminal stages; intermediate moves are broadcast-only.
|
||||
let dest_stage = additions.first().map_or("unknown", |(_, s)| *s);
|
||||
let should_commit = should_commit_stage(dest_stage);
|
||||
|
||||
if should_commit {
|
||||
slog!("[watcher] flush: {commit_msg}");
|
||||
match git_add_work_and_commit(git_root, &commit_msg) {
|
||||
Ok(committed) => {
|
||||
if committed {
|
||||
slog!("[watcher] committed: {commit_msg}");
|
||||
} else {
|
||||
slog!("[watcher] skipped (already committed): {commit_msg}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
slog!("[watcher] git error: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog!("[watcher] flush (broadcast-only): {commit_msg}");
|
||||
}
|
||||
|
||||
// For move operations, find the source stage from deleted entries with matching item_id.
|
||||
let from_stage: Option<String> = if !additions.is_empty() {
|
||||
pending
|
||||
.iter()
|
||||
.filter(|(path, _)| !path.exists())
|
||||
.find(|(path, _)| path.file_stem().and_then(|s| s.to_str()) == Some(item_id.as_str()))
|
||||
.map(|(_, stage)| stage.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Always broadcast the event so connected WebSocket clients stay in sync.
|
||||
let evt = WatcherEvent::WorkItem {
|
||||
stage: dest_stage.to_string(),
|
||||
item_id,
|
||||
action: action.to_string(),
|
||||
commit_msg,
|
||||
from_stage,
|
||||
};
|
||||
let _ = event_tx.send(evt);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user