huskies: merge 784
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
//! Low-level synchronous git operations for worktree management.
|
||||
use crate::slog;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub(crate) fn branch_name(story_id: &str) -> String {
|
||||
format!("feature/story-{story_id}")
|
||||
}
|
||||
|
||||
/// Detect the current branch of the project root (the base branch worktrees fork from).
|
||||
pub(crate) fn detect_base_branch(project_root: &Path) -> String {
|
||||
Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "master".to_string())
|
||||
}
|
||||
|
||||
/// Placeholder for worktree isolation of `.huskies/work/`.
|
||||
///
|
||||
/// Previous approaches (sparse checkout, skip-worktree) all leaked state
|
||||
/// from worktrees back to the main checkout's config/index. For now this
|
||||
/// is a no-op — merge conflicts from pipeline file moves are handled at
|
||||
/// merge time by the mergemaster (squash merge ignores work/ diffs).
|
||||
pub(crate) fn configure_sparse_checkout(_wt_path: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a git worktree at `wt_path` on `branch`, pruning stale references first.
|
||||
pub(crate) fn create_worktree_sync(
|
||||
project_root: &Path,
|
||||
wt_path: &Path,
|
||||
branch: &str,
|
||||
) -> Result<(), String> {
|
||||
// Ensure the parent directory exists
|
||||
if let Some(parent) = wt_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("Create worktree dir: {e}"))?;
|
||||
}
|
||||
|
||||
// Prune stale worktree references (e.g. directories deleted externally)
|
||||
let _ = Command::new("git")
|
||||
.args(["worktree", "prune"])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
// Try to create branch. If it already exists that's fine.
|
||||
let _ = Command::new("git")
|
||||
.args(["branch", branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
// Create worktree
|
||||
let output = Command::new("git")
|
||||
.args(["worktree", "add", &wt_path.to_string_lossy(), branch])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| format!("git worktree add: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// If it says already checked out, that's fine
|
||||
if stderr.contains("already checked out") || stderr.contains("already exists") {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("git worktree add failed: {stderr}"));
|
||||
}
|
||||
|
||||
// Enable sparse checkout to exclude pipeline files from the worktree.
|
||||
// This prevents .huskies/work/ changes from ending up in feature branches,
|
||||
// which cause rename/delete merge conflicts when merging back to master.
|
||||
configure_sparse_checkout(wt_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a git worktree directory and delete its branch.
|
||||
pub(crate) fn remove_worktree_sync(
|
||||
project_root: &Path,
|
||||
wt_path: &Path,
|
||||
branch: &str,
|
||||
) -> Result<(), String> {
|
||||
// Remove worktree
|
||||
let output = Command::new("git")
|
||||
.args(["worktree", "remove", "--force", &wt_path.to_string_lossy()])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| format!("git worktree remove: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("not a working tree") {
|
||||
// Orphaned directory: git doesn't recognise it as a worktree.
|
||||
// Remove the directory directly and prune stale git metadata.
|
||||
slog!(
|
||||
"[worktree] orphaned worktree detected, removing directory: {}",
|
||||
wt_path.display()
|
||||
);
|
||||
if let Err(e) = std::fs::remove_dir_all(wt_path) {
|
||||
slog!("[worktree] failed to remove orphaned directory: {e}");
|
||||
}
|
||||
let _ = Command::new("git")
|
||||
.args(["worktree", "prune"])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
} else {
|
||||
slog!("[worktree] remove warning: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
// Delete branch (best effort)
|
||||
let _ = Command::new("git")
|
||||
.args(["branch", "-d", branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the git worktree for a story if it exists, deriving the path and
|
||||
/// branch name deterministically from `project_root` and `story_id`.
|
||||
///
|
||||
/// Returns `Ok(())` if the worktree was removed or did not exist.
|
||||
/// Removal is best-effort: `remove_worktree_sync` logs failures internally
|
||||
/// but always returns `Ok`.
|
||||
pub fn prune_worktree_sync(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||
let wt_path = super::worktree_path(project_root, story_id);
|
||||
if !wt_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let branch = branch_name(story_id);
|
||||
remove_worktree_sync(project_root, &wt_path, &branch)
|
||||
}
|
||||
|
||||
/// Migrate filesystem artifacts for story IDs that were rewritten from slug form
|
||||
/// (`664_story_my_feature`) to numeric-only form (`664`).
|
||||
///
|
||||
/// For each `(old_id, new_id)` pair (as returned by
|
||||
/// `crdt_state::migrate_story_ids_to_numeric`), this function:
|
||||
/// 1. Moves the git worktree directory via `git worktree move` when it exists.
|
||||
/// 2. Renames the git branch from `feature/story-{old_id}` to `feature/story-{new_id}`.
|
||||
/// 3. Renames the log directory at `.huskies/logs/{old_id}` when it exists.
|
||||
///
|
||||
/// All steps are best-effort: failures are logged but do not abort the migration.
|
||||
/// Operations are skipped when the destination path already exists (idempotent).
|
||||
pub fn migrate_slug_paths(project_root: &Path, migrations: &[(String, String)]) {
|
||||
for (old_id, new_id) in migrations {
|
||||
// ── Worktree directory ──────────────────────────────────────────────
|
||||
let old_wt = super::worktree_path(project_root, old_id);
|
||||
let new_wt = super::worktree_path(project_root, new_id);
|
||||
|
||||
if old_wt.exists() && !new_wt.exists() {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"move",
|
||||
&old_wt.to_string_lossy(),
|
||||
&new_wt.to_string_lossy(),
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
slog!("[migrate] Moved worktree {old_id} → {new_id}");
|
||||
}
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
slog!(
|
||||
"[migrate] git worktree move failed for {old_id}: {stderr}; \
|
||||
falling back to directory rename"
|
||||
);
|
||||
if let Err(e) = std::fs::rename(&old_wt, &new_wt) {
|
||||
slog!("[migrate] Directory rename for worktree {old_id} failed: {e}");
|
||||
} else {
|
||||
slog!("[migrate] Renamed worktree directory {old_id} → {new_id}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
slog!("[migrate] git worktree move error for {old_id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Git branch ──────────────────────────────────────────────────────
|
||||
let old_branch = branch_name(old_id);
|
||||
let new_branch = branch_name(new_id);
|
||||
let out = Command::new("git")
|
||||
.args(["branch", "-m", &old_branch, &new_branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
slog!("[migrate] Renamed branch {old_branch} → {new_branch}");
|
||||
}
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
// Branch may not exist (story already merged/archived) — log at debug level.
|
||||
slog!("[migrate] Branch rename skipped for {old_id}: {stderr}");
|
||||
}
|
||||
Err(e) => {
|
||||
slog!("[migrate] Branch rename error for {old_id}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Log directory ───────────────────────────────────────────────────
|
||||
let old_log = project_root.join(".huskies").join("logs").join(old_id);
|
||||
let new_log = project_root.join(".huskies").join("logs").join(new_id);
|
||||
if old_log.exists() && !new_log.exists() {
|
||||
if let Err(e) = std::fs::rename(&old_log, &new_log) {
|
||||
slog!("[migrate] Log directory rename for {old_id} failed: {e}");
|
||||
} else {
|
||||
slog!("[migrate] Moved log directory {old_id} → {new_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn init_git_repo(dir: &Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git init");
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_name_format() {
|
||||
assert_eq!(branch_name("42_my_story"), "feature/story-42_my_story");
|
||||
assert_eq!(branch_name("1_test"), "feature/story-1_test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_id_branch_name_uses_number_only() {
|
||||
assert_eq!(branch_name("664"), "feature/story-664");
|
||||
assert_eq!(branch_name("730"), "feature/story-730");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_id_worktree_path_uses_number_only() {
|
||||
let project_root = Path::new("/home/user/my-project");
|
||||
let path = super::super::worktree_path(project_root, "664");
|
||||
assert_eq!(
|
||||
path,
|
||||
Path::new("/home/user/my-project/.huskies/worktrees/664")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_base_branch_returns_branch_in_git_repo() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project_root = tmp.path().join("my-project");
|
||||
fs::create_dir_all(&project_root).unwrap();
|
||||
init_git_repo(&project_root);
|
||||
|
||||
let branch = detect_base_branch(&project_root);
|
||||
assert!(!branch.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_base_branch_falls_back_to_master_for_non_git_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let branch = detect_base_branch(tmp.path());
|
||||
assert_eq!(branch, "master");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configure_sparse_checkout_is_noop() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
assert!(configure_sparse_checkout(tmp.path()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_worktree_after_stale_reference() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project_root = tmp.path().join("my-project");
|
||||
fs::create_dir_all(&project_root).unwrap();
|
||||
init_git_repo(&project_root);
|
||||
|
||||
let wt_path = tmp.path().join("my-worktree");
|
||||
let branch = "feature/test-stale";
|
||||
|
||||
// First creation should succeed
|
||||
create_worktree_sync(&project_root, &wt_path, branch).unwrap();
|
||||
assert!(wt_path.exists());
|
||||
|
||||
// Simulate external deletion (e.g., rm -rf by another agent)
|
||||
fs::remove_dir_all(&wt_path).unwrap();
|
||||
assert!(!wt_path.exists());
|
||||
|
||||
// Second creation should succeed despite stale git reference.
|
||||
// Without `git worktree prune`, this fails with "already checked out"
|
||||
// or "already exists".
|
||||
let result = create_worktree_sync(&project_root, &wt_path, branch);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Expected worktree creation to succeed after stale reference, got: {:?}",
|
||||
result.err()
|
||||
);
|
||||
assert!(wt_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_has_all_files_including_work() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project_root = tmp.path().join("my-project");
|
||||
fs::create_dir_all(&project_root).unwrap();
|
||||
init_git_repo(&project_root);
|
||||
|
||||
// Create a tracked file under .huskies/work/ on the initial branch
|
||||
let work_dir = project_root.join(".huskies").join("work");
|
||||
fs::create_dir_all(&work_dir).unwrap();
|
||||
fs::write(work_dir.join("test_story.md"), "# Test").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(&project_root)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "add work file"])
|
||||
.current_dir(&project_root)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let wt_path = tmp.path().join("my-worktree");
|
||||
let branch = "feature/test-sparse";
|
||||
create_worktree_sync(&project_root, &wt_path, branch).unwrap();
|
||||
|
||||
// Worktree should have all files including .huskies/work/
|
||||
assert!(wt_path.join(".huskies").join("work").exists());
|
||||
assert!(wt_path.join(".git").exists());
|
||||
|
||||
// Main checkout must NOT be affected by worktree creation.
|
||||
assert!(
|
||||
work_dir.exists(),
|
||||
".huskies/work/ must still exist in the main checkout"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_worktree_sync_removes_orphaned_directory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project_root = tmp.path().join("my-project");
|
||||
fs::create_dir_all(&project_root).unwrap();
|
||||
init_git_repo(&project_root);
|
||||
|
||||
// Create a directory that looks like a worktree but isn't registered with git
|
||||
let wt_path = project_root
|
||||
.join(".huskies")
|
||||
.join("worktrees")
|
||||
.join("orphan");
|
||||
fs::create_dir_all(&wt_path).unwrap();
|
||||
fs::write(wt_path.join("some_file.txt"), "stale").unwrap();
|
||||
assert!(wt_path.exists());
|
||||
|
||||
// git worktree remove will fail with "not a working tree",
|
||||
// but the fallback should rm -rf the directory
|
||||
remove_worktree_sync(&project_root, &wt_path, "feature/orphan").unwrap();
|
||||
assert!(!wt_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_worktree_sync_cleans_up_directory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project_root = tmp.path().join("my-project");
|
||||
fs::create_dir_all(&project_root).unwrap();
|
||||
init_git_repo(&project_root);
|
||||
|
||||
let wt_path = project_root
|
||||
.join(".huskies")
|
||||
.join("worktrees")
|
||||
.join("test_rm");
|
||||
create_worktree_sync(&project_root, &wt_path, "feature/test-rm").unwrap();
|
||||
assert!(wt_path.exists());
|
||||
|
||||
remove_worktree_sync(&project_root, &wt_path, "feature/test-rm").unwrap();
|
||||
assert!(!wt_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_worktree_sync_noop_when_no_worktree_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// No worktree directory exists — must return Ok without touching git.
|
||||
let result = prune_worktree_sync(tmp.path(), "42_story_nonexistent");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Expected Ok when worktree dir absent: {:?}",
|
||||
result.err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_worktree_sync_removes_real_worktree() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project_root = tmp.path().join("my-project");
|
||||
fs::create_dir_all(&project_root).unwrap();
|
||||
init_git_repo(&project_root);
|
||||
|
||||
let story_id = "55_story_prune_test";
|
||||
let wt_path = super::super::worktree_path(&project_root, story_id);
|
||||
create_worktree_sync(
|
||||
&project_root,
|
||||
&wt_path,
|
||||
&format!("feature/story-{story_id}"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(wt_path.exists(), "worktree dir should exist before prune");
|
||||
|
||||
let result = prune_worktree_sync(&project_root, story_id);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"prune_worktree_sync must return Ok: {:?}",
|
||||
result.err()
|
||||
);
|
||||
assert!(!wt_path.exists(), "worktree dir should be gone after prune");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user