diff --git a/bin/wt-add b/bin/wt-add index 445a19f..84d5d60 100755 --- a/bin/wt-add +++ b/bin/wt-add @@ -79,6 +79,9 @@ else fi wt_require_valid_config || exit 1 +# Source wt-adopt library for adoption treatment +wt_source wt-adopt + usage() { echo "Usage: $(basename "$0") [git worktree add arguments...]" echo @@ -140,81 +143,6 @@ while [[ $i -lt ${#ARGS[@]} ]]; do i=$((i + 1)) done -# Helper: install project metadata for a created worktree -install_metadata_for_worktree() { - local worktree_path="$1" - - # Normalize to absolute (worktree now exists) - local worktree_path_abs - worktree_path_abs="$(cd "$worktree_path" && pwd)" - - echo "Installing project metadata into worktree: $worktree_path_abs" - - # Find wt-metadata-import: try script directory first, then PATH - local metadata_import="" - if [[ -f "$SCRIPT_DIR/wt-metadata-import" ]]; then - metadata_import="$SCRIPT_DIR/wt-metadata-import" - elif command -v wt-metadata-import >/dev/null 2>&1; then - metadata_import="wt-metadata-import" - else - error "Cannot find wt-metadata-import" - return 1 - fi - - "$metadata_import" -y "$WT_IDEA_FILES_BASE" "$worktree_path_abs" -} - -# Helper: install Bazel output symlinks (bazel-out, bazel-bin, etc.) for a created worktree -# -# Bazel stores build outputs in a central cache directory (typically under /private/var/tmp). -# The symlinks (bazel-out, bazel-bin, etc.) in the repo root point to this shared cache. -# By copying these symlinks to new worktrees, we enable: -# 1. Faster IntelliJ/IDE sync (no rebuild required) -# 2. Shared build cache across worktrees -# 3. Immediate access to compiled artifacts -install_bazel_symlinks_for_worktree() { - local worktree_path="$1" - - # Normalize to absolute (worktree now exists) - local worktree_path_abs - worktree_path_abs="$(cd "$worktree_path" && pwd)" - - # List of Bazel symlinks to copy from main repo - # These are the most important ones for IntelliJ sync performance - local bazel_symlinks=("bazel-out" "bazel-bin" "bazel-testlogs" "bazel-genfiles") - - local main_repo_abs - main_repo_abs="$(cd "$WT_MAIN_REPO_ROOT" && pwd)" - - echo "Installing Bazel symlinks into worktree: $worktree_path_abs" - - for symlink_name in "${bazel_symlinks[@]}"; do - local src_link="$main_repo_abs/$symlink_name" - local dst_link="$worktree_path_abs/$symlink_name" - - # Check if source symlink exists in main repo - if [[ -L "$src_link" ]]; then - # Get the target of the symlink - local target - target="$(readlink "$src_link")" - - # If the target is relative, make it absolute based on main repo location - if [[ "$target" != /* ]]; then - target="$main_repo_abs/$target" - fi - - # Remove existing symlink/file in worktree if present - if [[ -L "$dst_link" || -e "$dst_link" ]]; then - rm -rf "$dst_link" - fi - - # Create symlink pointing to the same target - ln -s "$target" "$dst_link" - echo " ✓ $symlink_name -> $target" - fi - done -} - ######################################## # Fast path: NOT creating a new branch # ######################################## @@ -266,8 +194,7 @@ if [[ $creating_new_branch -eq 0 ]]; then echo "Creating worktree for existing branch '$local_branch' at: $worktree_path" git worktree add -- "$worktree_path" "$local_branch" - install_metadata_for_worktree "$worktree_path" - install_bazel_symlinks_for_worktree "$worktree_path" + wt_adopt_worktree --force "$(cd "$worktree_path" && pwd -P)" success "git worktree add completed successfully." exit 0 fi @@ -288,8 +215,7 @@ if [[ $creating_new_branch -eq 0 ]]; then info "Not creating a new branch; running: git worktree add ${ARGS[*]}" git worktree add "${ARGS[@]}" - install_metadata_for_worktree "$worktree_path" - install_bazel_symlinks_for_worktree "$worktree_path" + wt_adopt_worktree --force "$(cd "$worktree_path" && pwd -P)" success "git worktree add completed successfully." exit 0 fi @@ -475,8 +401,7 @@ else git worktree add "${FINAL_ARGS[@]}" fi -install_metadata_for_worktree "$worktree_path" -install_bazel_symlinks_for_worktree "$worktree_path" +wt_adopt_worktree --force "$(cd "$worktree_path" && pwd -P)" success "git worktree add completed successfully." exit 0 diff --git a/bin/wt-adopt b/bin/wt-adopt new file mode 100755 index 0000000..23b0999 --- /dev/null +++ b/bin/wt-adopt @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# +# wt-adopt — Adopt an existing worktree into wt management +# ========================================================== +# +# Takes any existing git worktree and gives it the "wt treatment": +# imports metadata from the vault, installs Bazel symlinks, and places +# a marker file so wt commands recognize it as managed. +# +# Usage: +# wt-adopt # Adopt the worktree at CWD +# wt-adopt # Adopt the specified worktree +# wt-adopt # Adopt the worktree for the given branch +# wt-adopt --redo # Re-run adoption treatment on current worktree +# wt-adopt --force # Skip conflict checks, overwrite without prompting +# +# Notes: +# - The main repository cannot be adopted (only worktrees). +# - Re-adopting an already-adopted worktree requires --redo or --force. +# - If the worktree has existing metadata, interactive sessions prompt +# with overwrite/keep/abort; non-interactive sessions abort. +# + +set -euo pipefail + +# Resolve script and lib directories +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_DIR="$SCRIPT_DIR/../lib" + +# Bootstrap: source wt-common from lib/ +if [[ -f "$LIB_DIR/wt-common" ]]; then + . "$LIB_DIR/wt-common" +elif [[ -f "$HOME/.wt/lib/wt-common" ]]; then + . "$HOME/.wt/lib/wt-common" +else + echo "Error: Cannot find wt-common" >&2 + exit 1 +fi +wt_require_valid_config || exit 1 + +# Source wt-adopt library +wt_source wt-adopt + +usage() { + cat < # Adopt specified worktree + $(basename "$0") --redo # Re-adopt current worktree + $(basename "$0") --redo --force # Re-adopt, overwrite without prompting +EOF +} + +TARGET_WORKTREE="" +FORCE=false +REDO=false + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --force) + FORCE=true + shift + ;; + --redo) + REDO=true + shift + ;; + -*) + error "Unknown option: $1" + usage >&2 + exit 1 + ;; + *) + if [[ -n "$TARGET_WORKTREE" ]]; then + error "Too many arguments" + usage >&2 + exit 1 + fi + TARGET_WORKTREE="$1" + shift + ;; + esac +done + +# Show context banner if contexts are configured +wt_show_context_banner + +# Resolve target worktree +if [[ -z "$TARGET_WORKTREE" ]]; then + # Resolve to worktree root, not CWD (user may be in a subdirectory) + TARGET_WORKTREE="$(git rev-parse --show-toplevel 2>/dev/null)" || { + error "Not inside a git repository" + exit 1 + } +else + TARGET_WORKTREE="$(wt_resolve_and_validate "$TARGET_WORKTREE")" || exit 1 +fi + +# Validate it's a git worktree or repo +if ! git -C "$TARGET_WORKTREE" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + error "Not a git repository or worktree: $TARGET_WORKTREE" + exit 1 +fi + +# Block adoption of the main repo +if wt_is_main_repo "$TARGET_WORKTREE"; then + error "Cannot adopt the main repository. Only worktrees can be adopted." + exit 1 +fi + +# Build args for wt_adopt_worktree +adopt_args=() +if [[ "$FORCE" == true ]]; then + adopt_args+=("--force") +fi +adopt_args+=("$TARGET_WORKTREE") + +# Check if already adopted +if wt_is_adopted "$TARGET_WORKTREE"; then + if [[ "$REDO" == true || "$FORCE" == true ]]; then + info "Re-running adoption treatment: $TARGET_WORKTREE" + wt_adopt_worktree "${adopt_args[@]}" + else + info "Worktree is already adopted: $TARGET_WORKTREE" + info "Use --redo to re-run adoption treatment, or --force to force." + fi + exit 0 +fi + +# Run adoption +wt_adopt_worktree "${adopt_args[@]}" diff --git a/completion/wt.bash b/completion/wt.bash index b05fa61..ac0c4c1 100644 --- a/completion/wt.bash +++ b/completion/wt.bash @@ -299,6 +299,7 @@ _wt_metadata_import_complete() { # --- Wire up standalone wt-* completions (only if commands exist on PATH) --- type wt-add >/dev/null 2>&1 && complete -F _wt_add_complete wt-add +type wt-adopt >/dev/null 2>&1 && complete -F _wt_switch_complete wt-adopt type wt-switch >/dev/null 2>&1 && complete -F _wt_switch_complete wt-switch type wt-remove >/dev/null 2>&1 && complete -F _wt_remove_complete wt-remove type wt-cd >/dev/null 2>&1 && complete -F _wt_cd_complete wt-cd @@ -318,7 +319,7 @@ _wt_completion_bash() { cur="${COMP_WORDS[COMP_CWORD]}" if [[ ${COMP_CWORD} -eq 1 ]]; then - local commands="add switch remove list cd context metadata-export metadata-import ijwb-export ijwb-import help" + local commands="add adopt switch remove list cd context metadata-export metadata-import ijwb-export ijwb-import help" COMPREPLY=($(compgen -W "$commands" -- "$cur")) else case "${COMP_WORDS[1]}" in @@ -327,6 +328,14 @@ _wt_completion_bash() { branches=$(git branch -a 2>/dev/null | sed 's/^[* ]*//' | sed 's|remotes/origin/||') COMPREPLY=($(compgen -W "$branches" -- "$cur")) ;; + adopt) + local branches + branches="$(wt_worktree_branch_list)" + if [[ -n "$branches" ]]; then + local IFS=$'\n' + COMPREPLY+=($(compgen -W "$branches" -- "$cur")) + fi + ;; switch|cd) local branches branches="$(wt_worktree_branch_list)" diff --git a/completion/wt.zsh b/completion/wt.zsh index 63f1ac3..b8288c7 100644 --- a/completion/wt.zsh +++ b/completion/wt.zsh @@ -294,6 +294,7 @@ fi # Register standalone wt-* completions # ═══════════════════════════════════════════════════════════════════════════════ +compdef _wt_switch wt-adopt compdef _wt_switch wt-switch compdef _wt_remove wt-remove compdef _wt_cd wt-cd @@ -312,6 +313,7 @@ _wt_completion() { local -a commands commands=( 'add:Create a new worktree for a branch' + 'adopt:Adopt an existing worktree into wt management' 'switch:Switch the active worktree symlink' 'remove:Remove a worktree' 'list:List all worktrees with status' @@ -343,6 +345,7 @@ _wt_completion() { branches=("${(f)$(git branch -a 2>/dev/null | sed 's/^[* ]*//' | sed 's|remotes/origin/||')}") (( ${#branches[@]} > 0 )) && _describe 'branch' branches ;; + adopt) _wt_switch ;; switch|cd) _wt_switch ;; remove) _wt_remove ;; context) _wt_context ;; diff --git a/lib/wt-adopt b/lib/wt-adopt new file mode 100644 index 0000000..cb12fc2 --- /dev/null +++ b/lib/wt-adopt @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +# +# wt-adopt — Adoption helpers for worktree management +# ==================================================== +# +# Provides functions for adopting existing worktrees into wt management. +# "Adopting" means: importing metadata, installing Bazel symlinks, and +# placing a marker file so wt knows this worktree is managed. +# +# Marker file location: +# $(git rev-parse --git-dir)/wt/adopted +# +# This resolves to: +# - .git/worktrees//wt/adopted (for worktrees) +# - .git/wt/adopted (for the main repo — but adoption is blocked) +# + +# ───────────────────────────────────────────────────────────────────────────── +# Marker file helpers +# ───────────────────────────────────────────────────────────────────────────── + +# Check if a worktree has been adopted by wt. +# Args: $1 = worktree path (default: CWD) +# Returns: 0 if adopted, 1 if not +wt_is_adopted() { + local worktree_path="${1:-.}" + local git_dir + git_dir="$(git -C "$worktree_path" rev-parse --git-dir 2>/dev/null)" || return 1 + + # Resolve to absolute path + if [[ "$git_dir" != /* ]]; then + git_dir="$(cd "$worktree_path" && cd "$git_dir" && pwd -P)" + fi + + [[ -f "$git_dir/wt/adopted" ]] +} + +# Mark a worktree as adopted. +# Args: $1 = worktree path (default: CWD) +# Returns: 0 on success, 1 on failure +wt_mark_adopted() { + local worktree_path="${1:-.}" + local git_dir + git_dir="$(git -C "$worktree_path" rev-parse --git-dir 2>/dev/null)" || return 1 + + if [[ "$git_dir" != /* ]]; then + git_dir="$(cd "$worktree_path" && cd "$git_dir" && pwd -P)" + fi + + mkdir -p "$git_dir/wt" + touch "$git_dir/wt/adopted" +} + +# Remove adoption marker from a worktree. +# Args: $1 = worktree path (default: CWD) +# Returns: 0 on success, 1 on failure +wt_unmark_adopted() { + local worktree_path="${1:-.}" + local git_dir + git_dir="$(git -C "$worktree_path" rev-parse --git-dir 2>/dev/null)" || return 1 + + if [[ "$git_dir" != /* ]]; then + git_dir="$(cd "$worktree_path" && cd "$git_dir" && pwd -P)" + fi + + rm -f "$git_dir/wt/adopted" + rmdir "$git_dir/wt" 2>/dev/null || true +} + +# Check if a path is the main repository (not a worktree). +# In the main repo, git-dir and git-common-dir resolve to the same place. +# In a worktree, git-dir is .git/worktrees/, common-dir is .git. +# Args: $1 = path (default: CWD) +# Returns: 0 if main repo, 1 if worktree or not a git repo +wt_is_main_repo() { + local path="${1:-.}" + local git_dir common_dir + git_dir="$(git -C "$path" rev-parse --git-dir 2>/dev/null)" || return 1 + common_dir="$(git -C "$path" rev-parse --git-common-dir 2>/dev/null)" || return 1 + + # Resolve both to absolute physical paths for comparison + local abs_git_dir abs_common_dir + abs_git_dir="$(cd "$path" && cd "$git_dir" && pwd -P)" + abs_common_dir="$(cd "$path" && cd "$common_dir" && pwd -P)" + + [[ "$abs_git_dir" == "$abs_common_dir" ]] +} + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +# Check if the current session is interactive (stdin and stderr are TTYs). +# Extracted as a function so tests can override it. +_wt_is_interactive() { [[ -t 0 && -t 2 ]]; } + +# ───────────────────────────────────────────────────────────────────────────── +# Conflict detection +# ───────────────────────────────────────────────────────────────────────────── + +# Check for existing files in a worktree that would be overwritten by adoption. +# +# Scans: +# - The vault (WT_IDEA_FILES_BASE) for metadata matching WT_METADATA_PATTERNS, +# then checks if the corresponding target paths exist in the worktree. +# This mirrors the same vault-scanning logic used by wt-metadata-import. +# - Bazel output directories that are real directories (not symlinks — +# symlinks are always safe to replace) +# +# Args: $1 = worktree path (absolute) +# Outputs: conflicting relative paths to stdout (one per line) +# Returns: 0 if conflicts found, 1 if no conflicts +wt_check_adoption_conflicts() { + local worktree_path="$1" + local -a conflicts=() + local vault="${WT_IDEA_FILES_BASE:-}" + + # Check metadata patterns by scanning the vault (same as wt-metadata-import) + if [[ -n "$vault" && -d "$vault" ]]; then + for pattern in ${WT_METADATA_PATTERNS:-}; do + while IFS= read -r -d '' meta_src; do + # Compute relative path from vault root (mirrors import_pattern logic) + local parent_src + parent_src="$(dirname "$meta_src")" + local rel_path="" + if [[ "$parent_src" != "$vault" ]]; then + rel_path="${parent_src#"$vault"/}" + fi + + # Map to corresponding target in worktree + local target_meta + if [[ -z "$rel_path" ]]; then + target_meta="$worktree_path/$pattern" + else + target_meta="$worktree_path/$rel_path/$pattern" + fi + + # Check if target already exists + if [[ -e "$target_meta" || -L "$target_meta" ]]; then + if [[ -z "$rel_path" ]]; then + conflicts+=("$pattern") + else + conflicts+=("$rel_path/$pattern") + fi + fi + done < <(find -L "$vault" \( -type l -o -type d \) -name "$pattern" -print0 2>/dev/null) + done + fi + + # Check Bazel dirs (only real dirs, not symlinks) + local -a bazel_names=("bazel-out" "bazel-bin" "bazel-testlogs" "bazel-genfiles") + for name in "${bazel_names[@]}"; do + local target="$worktree_path/$name" + if [[ -d "$target" && ! -L "$target" ]]; then + conflicts+=("$name (real directory)") + fi + done + + if [[ ${#conflicts[@]} -eq 0 ]]; then + return 1 + fi + + printf '%s\n' "${conflicts[@]}" + return 0 +} + +# ───────────────────────────────────────────────────────────────────────────── +# Treatment functions +# ───────────────────────────────────────────────────────────────────────────── + +# Install Bazel output symlinks into a worktree. +# Copies bazel-out, bazel-bin, etc. from the main repo so the worktree +# shares the build cache (faster IDE sync, no rebuild needed). +# Args: $1 = worktree path (absolute) +wt_install_bazel_symlinks() { + local worktree_path="$1" + + local bazel_symlinks=("bazel-out" "bazel-bin" "bazel-testlogs" "bazel-genfiles") + + local main_repo_abs + main_repo_abs="$(cd "$WT_MAIN_REPO_ROOT" && pwd)" + + info "Installing Bazel symlinks into worktree: $worktree_path" + + for symlink_name in "${bazel_symlinks[@]}"; do + local src_link="$main_repo_abs/$symlink_name" + local dst_link="$worktree_path/$symlink_name" + + if [[ -L "$src_link" ]]; then + local target + target="$(readlink "$src_link")" + + if [[ "$target" != /* ]]; then + target="$main_repo_abs/$target" + fi + + if [[ -L "$dst_link" || -e "$dst_link" ]]; then + rm -rf "$dst_link" + fi + + ln -s "$target" "$dst_link" + info " $symlink_name -> $target" + fi + done +} + +# Run the full adoption treatment on a worktree. +# 1. Check for conflicting files (unless --force) +# 2. Import metadata from vault (unless keep_existing) +# 3. Install Bazel symlinks (always) +# 4. Place adoption marker +# +# Args: [--force] +# --force: skip conflict check (used by wt-add for fresh worktrees) +# Requires: WT_IDEA_FILES_BASE to be set +# Returns: 0 on success, 1 if aborted due to conflicts +wt_adopt_worktree() { + local worktree_path="" + local force=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --force) force=true ;; + *) worktree_path="$1" ;; + esac + shift + done + + local keep_existing=false + + # 1. Conflict check + if [[ "$force" != true ]]; then + local conflict_list + if conflict_list="$(wt_check_adoption_conflicts "$worktree_path")"; then + warn "The following files in the worktree will be overwritten by adoption:" + while IFS= read -r item; do + echo " - $item" >&2 + done <<< "$conflict_list" + echo >&2 + + if _wt_is_interactive; then + echo " [O]verwrite — replace with vault contents" >&2 + echo " [K]eep — adopt without importing metadata (Bazel symlinks still installed)" >&2 + echo " [A]bort — cancel adoption" >&2 + echo >&2 + + local response + if ! read -erp "Choose [O/K/A]: " response; then + echo >&2 + return 1 + fi + + case "$response" in + [oO]|[oO][vV]*) + # Overwrite — proceed with full treatment + ;; + [kK]|[kK][eE]*) + # Keep — skip metadata import + keep_existing=true + ;; + *) + echo "Aborted." >&2 + return 1 + ;; + esac + else + error "Aborting: worktree has existing files that would be overwritten (use --force to override)" + return 1 + fi + fi + fi + + # 2. Import metadata (unless keeping existing files) + if [[ "$keep_existing" != true ]]; then + local metadata_import="" + if [[ -n "${SCRIPT_DIR:-}" && -x "$SCRIPT_DIR/wt-metadata-import" ]]; then + metadata_import="$SCRIPT_DIR/wt-metadata-import" + elif [[ -x "$HOME/.wt/bin/wt-metadata-import" ]]; then + metadata_import="$HOME/.wt/bin/wt-metadata-import" + elif command -v wt-metadata-import >/dev/null 2>&1; then + metadata_import="wt-metadata-import" + fi + + if [[ -n "$metadata_import" && -d "${WT_IDEA_FILES_BASE:-}" ]]; then + info "Importing metadata into worktree: $worktree_path" + if ! "$metadata_import" -y "$WT_IDEA_FILES_BASE" "$worktree_path"; then + error "Metadata import failed — worktree not adopted" + return 1 + fi + fi + else + info "Keeping existing metadata files" + fi + + # 3. Install Bazel symlinks (always — even with keep_existing) + wt_install_bazel_symlinks "$worktree_path" + + # 4. Mark as adopted + wt_mark_adopted "$worktree_path" + + success "Worktree adopted: $worktree_path" +} diff --git a/test/integration/wt-add.bats b/test/integration/wt-add.bats index 5c24de4..e8aee42 100644 --- a/test/integration/wt-add.bats +++ b/test/integration/wt-add.bats @@ -32,6 +32,7 @@ teardown() { run "$TEST_HOME/.wt/bin/wt-add" existing-branch assert_success assert_is_worktree "$WT_WORKTREES_BASE/existing-branch" + assert_is_adopted "$WT_WORKTREES_BASE/existing-branch" local branch=$(cd "$WT_WORKTREES_BASE/existing-branch" && git branch --show-current) assert_equal "$branch" "existing-branch" } @@ -43,6 +44,7 @@ teardown() { run "$TEST_HOME/.wt/bin/wt-add" "$custom_path" feature-path assert_success assert_is_worktree "$custom_path" + assert_is_adopted "$custom_path" local branch=$(cd "$custom_path" && git branch --show-current) assert_equal "$branch" "feature-path" } @@ -55,6 +57,7 @@ teardown() { run "$TEST_HOME/.wt/bin/wt-add" -b new-feature assert_success assert_is_worktree "$WT_WORKTREES_BASE/new-feature" + assert_is_adopted "$WT_WORKTREES_BASE/new-feature" local branch=$(cd "$WT_WORKTREES_BASE/new-feature" && git branch --show-current) assert_equal "$branch" "new-feature" } diff --git a/test/integration/wt-adopt.bats b/test/integration/wt-adopt.bats new file mode 100644 index 0000000..5909605 --- /dev/null +++ b/test/integration/wt-adopt.bats @@ -0,0 +1,420 @@ +#!/usr/bin/env bats + +# Integration tests for bin/wt-adopt + +setup() { + load '../test_helper/common' + setup_test_env + + REPO=$(create_mock_repo_with_remote "$BATS_TEST_TMPDIR/repo") + create_test_context "test" "$REPO" + load_test_context "test" + export WT_METADATA_PATTERNS="" +} + +teardown() { + teardown_test_env +} + +# ============================================================================= +# Basic adoption +# ============================================================================= + +@test "adopt worktree by path argument" { + create_branch "$REPO" "feature-a" + local wt="$BATS_TEST_TMPDIR/wt/feature-a" + create_worktree "$REPO" "$wt" "feature-a" + wt="$(cd "$wt" && pwd -P)" + + run "$TEST_HOME/.wt/bin/wt-adopt" "$wt" + assert_success + assert_is_adopted "$wt" +} + +@test "adopt worktree by branch name" { + create_branch "$REPO" "feature-b" + create_worktree "$REPO" "$WT_WORKTREES_BASE/feature-b" "feature-b" + + run "$TEST_HOME/.wt/bin/wt-adopt" "feature-b" + assert_success + assert_is_adopted "$WT_WORKTREES_BASE/feature-b" +} + +@test "adopt worktree at CWD" { + create_branch "$REPO" "feature-c" + local wt="$BATS_TEST_TMPDIR/wt/feature-c" + create_worktree "$REPO" "$wt" "feature-c" + wt="$(cd "$wt" && pwd -P)" + + run bash -c 'cd "'"$wt"'" && "'"$TEST_HOME/.wt/bin/wt-adopt"'"' + assert_success + assert_is_adopted "$wt" +} + +# ============================================================================= +# Idempotency +# ============================================================================= + +@test "adopt already-adopted worktree is noop (non-interactive)" { + create_branch "$REPO" "feature-d" + local wt="$BATS_TEST_TMPDIR/wt/feature-d" + create_worktree "$REPO" "$wt" "feature-d" + wt="$(cd "$wt" && pwd -P)" + + # Adopt first time + run "$TEST_HOME/.wt/bin/wt-adopt" "$wt" + assert_success + + # Adopt again (stdin from /dev/null = non-interactive) + run "$TEST_HOME/.wt/bin/wt-adopt" "$wt" < /dev/null + assert_success + assert_output --partial "already adopted" + assert_output --partial "--redo" +} + +# ============================================================================= +# Error cases +# ============================================================================= + +@test "adopt main repo fails with error" { + run "$TEST_HOME/.wt/bin/wt-adopt" "$REPO" + assert_failure + assert_output --partial "Cannot adopt the main repository" +} + +@test "adopt non-git-directory fails with error" { + local tmpdir="$BATS_TEST_TMPDIR/not-a-repo" + mkdir -p "$tmpdir" + + run "$TEST_HOME/.wt/bin/wt-adopt" "$tmpdir" + assert_failure +} + +@test "adopt with too many arguments fails" { + run "$TEST_HOME/.wt/bin/wt-adopt" arg1 arg2 + assert_failure +} + +@test "adopt --help shows usage" { + run "$TEST_HOME/.wt/bin/wt-adopt" --help + assert_success + assert_output --partial "Adopt an existing worktree" +} + +# ============================================================================= +# Worktree location flexibility +# ============================================================================= + +@test "adopt worktree outside worktrees base directory" { + create_branch "$REPO" "feature-outside" + local wt="$BATS_TEST_TMPDIR/elsewhere/feature-outside" + create_worktree "$REPO" "$wt" "feature-outside" + wt="$(cd "$wt" && pwd -P)" + + run "$TEST_HOME/.wt/bin/wt-adopt" "$wt" + assert_success + assert_is_adopted "$wt" +} + +# ============================================================================= +# Treatment: metadata import +# ============================================================================= + +@test "adopt imports metadata when patterns configured" { + # Patch .conf for metadata patterns + sed -i.bak 's/WT_METADATA_PATTERNS=""/WT_METADATA_PATTERNS=".idea"/' \ + "$TEST_HOME/.wt/repos/test.conf" + + # Create branch BEFORE metadata dirs so create_branch's "git add ." doesn't + # commit .idea (which would remove it from main's working tree on checkout) + create_branch "$REPO" "feature-meta" + + # Now create metadata dirs and export to vault + create_metadata_dirs "$REPO" ".idea" + run "$TEST_HOME/.wt/bin/wt-metadata-export" -y "$REPO" "$WT_IDEA_FILES_BASE" + assert_success + + # Create worktree and adopt + local wt="$BATS_TEST_TMPDIR/wt/feature-meta" + create_worktree "$REPO" "$wt" "feature-meta" + wt="$(cd "$wt" && pwd -P)" + + run "$TEST_HOME/.wt/bin/wt-adopt" "$wt" + assert_success + assert_is_adopted "$wt" + # Metadata should be present in worktree + assert [ -d "$wt/.idea" ] +} + +# ============================================================================= +# Treatment: Bazel symlinks +# ============================================================================= + +@test "adopt installs Bazel symlinks when present" { + # Create fake bazel symlinks in main repo + ln -s "/fake/bazel/output" "$REPO/bazel-out" + ln -s "/fake/bazel/bin" "$REPO/bazel-bin" + + create_branch "$REPO" "feature-bazel" + local wt="$BATS_TEST_TMPDIR/wt/feature-bazel" + create_worktree "$REPO" "$wt" "feature-bazel" + wt="$(cd "$wt" && pwd -P)" + + run "$TEST_HOME/.wt/bin/wt-adopt" "$wt" + assert_success + assert [ -L "$wt/bazel-out" ] + assert [ -L "$wt/bazel-bin" ] +} + +# ============================================================================= +# Conflict safety +# ============================================================================= + +@test "adopt aborts non-interactive when worktree has existing metadata" { + # Set up patterns and vault + sed -i.bak 's/WT_METADATA_PATTERNS=""/WT_METADATA_PATTERNS=".idea"/' \ + "$TEST_HOME/.wt/repos/test.conf" + create_branch "$REPO" "conflict-abort" + create_metadata_dirs "$REPO" ".idea" + run "$TEST_HOME/.wt/bin/wt-metadata-export" -y "$REPO" "$WT_IDEA_FILES_BASE" + assert_success + + # Create worktree and add pre-existing .idea with known content + local wt="$BATS_TEST_TMPDIR/wt/conflict-abort" + create_worktree "$REPO" "$wt" "conflict-abort" + wt="$(cd "$wt" && pwd -P)" + mkdir -p "$wt/.idea" + echo "original-content" > "$wt/.idea/my-config.xml" + + # Non-interactive: should abort + run "$TEST_HOME/.wt/bin/wt-adopt" "$wt" < /dev/null + assert_failure + assert_output --partial "existing files" + + # Original .idea should be preserved + assert [ -f "$wt/.idea/my-config.xml" ] + assert_equal "$(cat "$wt/.idea/my-config.xml")" "original-content" + + # Should NOT be adopted + refute_is_adopted "$wt" +} + +@test "adopt with --force overwrites existing metadata" { + # Set up patterns and vault + sed -i.bak 's/WT_METADATA_PATTERNS=""/WT_METADATA_PATTERNS=".idea"/' \ + "$TEST_HOME/.wt/repos/test.conf" + create_branch "$REPO" "conflict-force" + create_metadata_dirs "$REPO" ".idea" + run "$TEST_HOME/.wt/bin/wt-metadata-export" -y "$REPO" "$WT_IDEA_FILES_BASE" + assert_success + + # Create worktree and add pre-existing .idea + local wt="$BATS_TEST_TMPDIR/wt/conflict-force" + create_worktree "$REPO" "$wt" "conflict-force" + wt="$(cd "$wt" && pwd -P)" + mkdir -p "$wt/.idea" + echo "will-be-replaced" > "$wt/.idea/old.xml" + + # --force should succeed + run "$TEST_HOME/.wt/bin/wt-adopt" --force "$wt" + assert_success + assert_is_adopted "$wt" + # .idea should be replaced with vault contents + assert [ -d "$wt/.idea" ] +} + +@test "adopt proceeds when no conflicts exist" { + # No metadata patterns, no vault content — clean worktree + create_branch "$REPO" "no-conflict" + local wt="$BATS_TEST_TMPDIR/wt/no-conflict" + create_worktree "$REPO" "$wt" "no-conflict" + wt="$(cd "$wt" && pwd -P)" + + # Non-interactive should succeed (no conflicts) + run "$TEST_HOME/.wt/bin/wt-adopt" "$wt" < /dev/null + assert_success + assert_is_adopted "$wt" +} + +# ============================================================================= +# --redo flag +# ============================================================================= + +@test "--redo re-runs treatment on adopted worktree" { + create_branch "$REPO" "redo-test" + local wt="$BATS_TEST_TMPDIR/wt/redo-test" + create_worktree "$REPO" "$wt" "redo-test" + wt="$(cd "$wt" && pwd -P)" + + # First adopt + run "$TEST_HOME/.wt/bin/wt-adopt" "$wt" + assert_success + assert_is_adopted "$wt" + + # Re-run with --redo --force + run "$TEST_HOME/.wt/bin/wt-adopt" --redo --force "$wt" + assert_success + assert_output --partial "Re-running adoption treatment" + assert_is_adopted "$wt" +} + +@test "--redo on unadopted worktree just adopts" { + create_branch "$REPO" "redo-unadopted" + local wt="$BATS_TEST_TMPDIR/wt/redo-unadopted" + create_worktree "$REPO" "$wt" "redo-unadopted" + wt="$(cd "$wt" && pwd -P)" + + run "$TEST_HOME/.wt/bin/wt-adopt" --redo "$wt" + assert_success + assert_is_adopted "$wt" +} + +@test "already adopted without --redo shows info and hint" { + create_branch "$REPO" "hint-test" + local wt="$BATS_TEST_TMPDIR/wt/hint-test" + create_worktree "$REPO" "$wt" "hint-test" + wt="$(cd "$wt" && pwd -P)" + + # First adopt + run "$TEST_HOME/.wt/bin/wt-adopt" "$wt" + assert_success + + # Run again without --redo + run "$TEST_HOME/.wt/bin/wt-adopt" "$wt" + assert_success + assert_output --partial "already adopted" + assert_output --partial "--redo" +} + +@test "--redo without --force still checks conflicts on re-run" { + # Set up patterns and vault + sed -i.bak 's/WT_METADATA_PATTERNS=""/WT_METADATA_PATTERNS=".idea"/' \ + "$TEST_HOME/.wt/repos/test.conf" + create_branch "$REPO" "redo-conflict" + create_metadata_dirs "$REPO" ".idea" + run "$TEST_HOME/.wt/bin/wt-metadata-export" -y "$REPO" "$WT_IDEA_FILES_BASE" + assert_success + + # Create worktree, adopt with --force (first time) + local wt="$BATS_TEST_TMPDIR/wt/redo-conflict" + create_worktree "$REPO" "$wt" "redo-conflict" + wt="$(cd "$wt" && pwd -P)" + run "$TEST_HOME/.wt/bin/wt-adopt" --force "$wt" + assert_success + assert_is_adopted "$wt" + + # Now .idea exists in worktree (from vault import). + # --redo without --force in non-interactive → should abort due to conflict + run "$TEST_HOME/.wt/bin/wt-adopt" --redo "$wt" < /dev/null + assert_failure + assert_output --partial "existing files" +} + +@test "--force on already adopted re-runs treatment" { + create_branch "$REPO" "force-redo" + local wt="$BATS_TEST_TMPDIR/wt/force-redo" + create_worktree "$REPO" "$wt" "force-redo" + wt="$(cd "$wt" && pwd -P)" + + # First adopt + run "$TEST_HOME/.wt/bin/wt-adopt" "$wt" + assert_success + + # --force alone on already-adopted should also re-run + run "$TEST_HOME/.wt/bin/wt-adopt" --force "$wt" + assert_success + assert_output --partial "Re-running adoption treatment" +} + +# ============================================================================= +# Keep existing files option +# ============================================================================= + +@test "adopt keep preserves existing metadata but installs bazel symlinks" { + # Set up patterns and vault + sed -i.bak 's/WT_METADATA_PATTERNS=""/WT_METADATA_PATTERNS=".idea"/' \ + "$TEST_HOME/.wt/repos/test.conf" + create_branch "$REPO" "keep-test" + create_metadata_dirs "$REPO" ".idea" + run "$TEST_HOME/.wt/bin/wt-metadata-export" -y "$REPO" "$WT_IDEA_FILES_BASE" + assert_success + + # Create fake bazel symlinks in main repo + ln -s "/fake/bazel/output" "$REPO/bazel-out" + + # Create worktree with pre-existing .idea containing known content + local wt="$BATS_TEST_TMPDIR/wt/keep-test" + create_worktree "$REPO" "$wt" "keep-test" + wt="$(cd "$wt" && pwd -P)" + mkdir -p "$wt/.idea" + echo "my-precious-config" > "$wt/.idea/workspace.xml" + + # Override _wt_is_interactive so the prompt appears even in a pipe, + # then pipe "k" (keep) to choose the keep-existing-files option. + run bash -c ' + source "'"$TEST_HOME/.wt/lib/wt-common"'" + source "'"$TEST_HOME/.wt/lib/wt-adopt"'" + _wt_is_interactive() { return 0; } + echo "k" | wt_adopt_worktree "'"$wt"'" + ' + assert_success + assert_is_adopted "$wt" + + # .idea should have the ORIGINAL content (not vault) + assert [ -f "$wt/.idea/workspace.xml" ] + assert_equal "$(cat "$wt/.idea/workspace.xml")" "my-precious-config" + + # Bazel symlinks should still be installed + assert [ -L "$wt/bazel-out" ] +} + +# ============================================================================= +# Subdirectory adoption (CWD resolves to worktree root) +# ============================================================================= + +@test "adopt from subdirectory resolves to worktree root" { + create_branch "$REPO" "subdir-test" + local wt="$BATS_TEST_TMPDIR/wt/subdir-test" + create_worktree "$REPO" "$wt" "subdir-test" + wt="$(cd "$wt" && pwd -P)" + + # Create a subdirectory inside the worktree + mkdir -p "$wt/some/deep/subdir" + + # Run wt-adopt from the subdirectory (no arguments = CWD mode) + run bash -c 'cd "'"$wt/some/deep/subdir"'" && "'"$TEST_HOME/.wt/bin/wt-adopt"'"' + assert_success + assert_is_adopted "$wt" +} + +# ============================================================================= +# Metadata import failure prevents adoption +# ============================================================================= + +@test "adopt fails and does not stamp marker when metadata import fails" { + sed -i.bak 's/WT_METADATA_PATTERNS=""/WT_METADATA_PATTERNS=".idea"/' \ + "$TEST_HOME/.wt/repos/test.conf" + + # Create vault so the -d check passes + mkdir -p "$WT_IDEA_FILES_BASE/.idea" + + # Replace wt-metadata-import with a stub that always fails + cat > "$TEST_HOME/.wt/bin/wt-metadata-import" <<'STUB' +#!/usr/bin/env bash +echo "simulated import failure" >&2 +exit 1 +STUB + chmod +x "$TEST_HOME/.wt/bin/wt-metadata-import" + + create_branch "$REPO" "import-fail" + local wt="$BATS_TEST_TMPDIR/wt/import-fail" + create_worktree "$REPO" "$wt" "import-fail" + wt="$(cd "$wt" && pwd -P)" + + run "$TEST_HOME/.wt/bin/wt-adopt" --force "$wt" + assert_failure + assert_output --partial "import failed" + + # Should NOT be adopted + refute_is_adopted "$wt" +} diff --git a/test/test_helper/common.bash b/test/test_helper/common.bash index 93fe91f..b07bdd1 100644 --- a/test/test_helper/common.bash +++ b/test/test_helper/common.bash @@ -272,3 +272,25 @@ skip_if_no_git() { skip "Test requires git" fi } + +# Check if a worktree has the adoption marker +assert_is_adopted() { + local worktree="$1" + local git_dir + git_dir="$(git -C "$worktree" rev-parse --git-dir)" + if [[ "$git_dir" != /* ]]; then + git_dir="$(cd "$worktree" && cd "$git_dir" && pwd -P)" + fi + assert [ -f "$git_dir/wt/adopted" ] +} + +# Check that a worktree does NOT have the adoption marker +refute_is_adopted() { + local worktree="$1" + local git_dir + git_dir="$(git -C "$worktree" rev-parse --git-dir)" + if [[ "$git_dir" != /* ]]; then + git_dir="$(cd "$worktree" && cd "$git_dir" && pwd -P)" + fi + assert [ ! -f "$git_dir/wt/adopted" ] +} diff --git a/test/unit/wt-adopt.bats b/test/unit/wt-adopt.bats new file mode 100644 index 0000000..1a95198 --- /dev/null +++ b/test/unit/wt-adopt.bats @@ -0,0 +1,201 @@ +#!/usr/bin/env bats + +# Unit tests for lib/wt-adopt + +setup() { + load '../test_helper/common' + setup_test_env + source "$TEST_HOME/.wt/lib/wt-common" + source "$TEST_HOME/.wt/lib/wt-adopt" + + REPO=$(create_mock_repo "$BATS_TEST_TMPDIR/repo") + create_branch "$REPO" "test-branch" + + WORKTREE="$BATS_TEST_TMPDIR/wt/test-branch" + create_worktree "$REPO" "$WORKTREE" "test-branch" + # Normalize path for consistent comparisons + WORKTREE="$(cd "$WORKTREE" && pwd -P)" + + export WT_MAIN_REPO_ROOT="$REPO" +} + +teardown() { + teardown_test_env +} + +# ============================================================================= +# wt_is_adopted / wt_mark_adopted / wt_unmark_adopted +# ============================================================================= + +@test "wt_is_adopted returns false for fresh worktree" { + run wt_is_adopted "$WORKTREE" + assert_failure +} + +@test "wt_mark_adopted creates marker file in correct git-dir location" { + wt_mark_adopted "$WORKTREE" + + # Marker should be inside the worktree's git dir, NOT in the working tree + local git_dir + git_dir="$(git -C "$WORKTREE" rev-parse --git-dir)" + if [[ "$git_dir" != /* ]]; then + git_dir="$(cd "$WORKTREE" && cd "$git_dir" && pwd -P)" + fi + assert [ -f "$git_dir/wt/adopted" ] + # In a worktree, .git is a file, not a directory — marker should NOT be at .git/wt/adopted + assert [ ! -d "$WORKTREE/.git/wt" ] +} + +@test "wt_is_adopted returns true after marking" { + wt_mark_adopted "$WORKTREE" + run wt_is_adopted "$WORKTREE" + assert_success +} + +@test "wt_unmark_adopted removes marker and wt directory" { + wt_mark_adopted "$WORKTREE" + run wt_is_adopted "$WORKTREE" + assert_success + + wt_unmark_adopted "$WORKTREE" + run wt_is_adopted "$WORKTREE" + assert_failure + + # wt/ directory should also be cleaned up + local git_dir + git_dir="$(git -C "$WORKTREE" rev-parse --git-dir)" + if [[ "$git_dir" != /* ]]; then + git_dir="$(cd "$WORKTREE" && cd "$git_dir" && pwd -P)" + fi + assert [ ! -d "$git_dir/wt" ] +} + +@test "wt_mark_adopted is idempotent" { + wt_mark_adopted "$WORKTREE" + wt_mark_adopted "$WORKTREE" + run wt_is_adopted "$WORKTREE" + assert_success +} + +# ============================================================================= +# wt_is_main_repo +# ============================================================================= + +@test "wt_is_main_repo returns true for main repo" { + run wt_is_main_repo "$REPO" + assert_success +} + +@test "wt_is_main_repo returns false for worktree" { + run wt_is_main_repo "$WORKTREE" + assert_failure +} + +@test "wt_is_main_repo returns failure for non-git directory" { + local tmpdir="$BATS_TEST_TMPDIR/not-a-repo" + mkdir -p "$tmpdir" + run wt_is_main_repo "$tmpdir" + assert_failure +} + +# ============================================================================= +# wt_install_bazel_symlinks +# ============================================================================= + +@test "wt_install_bazel_symlinks copies symlinks from main repo" { + # Create fake bazel symlinks in main repo + ln -s "/fake/bazel/output" "$REPO/bazel-out" + ln -s "/fake/bazel/bin" "$REPO/bazel-bin" + + wt_install_bazel_symlinks "$WORKTREE" + + assert [ -L "$WORKTREE/bazel-out" ] + assert [ -L "$WORKTREE/bazel-bin" ] + local target + target="$(readlink "$WORKTREE/bazel-out")" + assert_equal "$target" "/fake/bazel/output" +} + +@test "wt_install_bazel_symlinks skips missing symlinks" { + # Main repo has no bazel symlinks + wt_install_bazel_symlinks "$WORKTREE" + + assert [ ! -L "$WORKTREE/bazel-out" ] + assert [ ! -L "$WORKTREE/bazel-bin" ] +} + +# ============================================================================= +# wt_check_adoption_conflicts +# ============================================================================= + +@test "wt_check_adoption_conflicts returns 1 when no conflicts" { + export WT_METADATA_PATTERNS=".idea" + # Empty vault, empty worktree + mkdir -p "$BATS_TEST_TMPDIR/vault" + export WT_IDEA_FILES_BASE="$BATS_TEST_TMPDIR/vault" + + run wt_check_adoption_conflicts "$WORKTREE" + assert_failure # return 1 = no conflicts +} + +@test "wt_check_adoption_conflicts detects metadata from vault scan" { + export WT_METADATA_PATTERNS=".idea" + mkdir -p "$BATS_TEST_TMPDIR/vault/.idea" + export WT_IDEA_FILES_BASE="$BATS_TEST_TMPDIR/vault" + + # Create matching .idea in worktree + mkdir -p "$WORKTREE/.idea" + + run wt_check_adoption_conflicts "$WORKTREE" + assert_success # return 0 = conflicts found + assert_output --partial ".idea" +} + +@test "wt_check_adoption_conflicts detects nested metadata from vault" { + export WT_METADATA_PATTERNS=".ijwb" + mkdir -p "$BATS_TEST_TMPDIR/vault/subdir/.ijwb" + export WT_IDEA_FILES_BASE="$BATS_TEST_TMPDIR/vault" + + # Create matching nested path in worktree + mkdir -p "$WORKTREE/subdir/.ijwb" + + run wt_check_adoption_conflicts "$WORKTREE" + assert_success + assert_output --partial "subdir/.ijwb" +} + +@test "wt_check_adoption_conflicts skips metadata not in vault" { + export WT_METADATA_PATTERNS=".idea" + # Vault exists but does NOT contain .idea + mkdir -p "$BATS_TEST_TMPDIR/vault" + export WT_IDEA_FILES_BASE="$BATS_TEST_TMPDIR/vault" + + # Worktree has .idea but vault doesn't → no conflict + mkdir -p "$WORKTREE/.idea" + + run wt_check_adoption_conflicts "$WORKTREE" + assert_failure # no conflicts +} + +@test "wt_check_adoption_conflicts ignores bazel symlinks" { + export WT_METADATA_PATTERNS="" + export WT_IDEA_FILES_BASE="" + + # Create bazel-out as a symlink in worktree + ln -s "/some/target" "$WORKTREE/bazel-out" + + run wt_check_adoption_conflicts "$WORKTREE" + assert_failure # no conflicts (symlinks are safe) +} + +@test "wt_check_adoption_conflicts detects bazel real directory" { + export WT_METADATA_PATTERNS="" + export WT_IDEA_FILES_BASE="" + + # Create bazel-out as a real directory in worktree + mkdir -p "$WORKTREE/bazel-out" + + run wt_check_adoption_conflicts "$WORKTREE" + assert_success + assert_output --partial "bazel-out (real directory)" +} diff --git a/test/unit/wt-sh.bats b/test/unit/wt-sh.bats index 7d120cb..d0a9705 100644 --- a/test/unit/wt-sh.bats +++ b/test/unit/wt-sh.bats @@ -15,6 +15,10 @@ teardown() { # Tests for _WT_ROOT consistency # ============================================================================= +@test "wt.sh dispatches adopt subcommand" { + grep -q 'adopt)' "$PROJECT_ROOT/wt.sh" +} + @test "_WT_ROOT default in wt.sh matches INSTALL_DIR in install.sh" { # Extract the default path from wt.sh: _WT_ROOT="${_WT_ROOT:-$HOME/.wt}" local wt_line diff --git a/wt.sh b/wt.sh index e27b066..1f1d36c 100755 --- a/wt.sh +++ b/wt.sh @@ -91,6 +91,7 @@ wt() { case "$cmd" in add) _wt_run wt-add "$@" ;; + adopt) _wt_run wt-adopt "$@" ;; switch) _wt_run wt-switch "$@" ;; remove) _wt_run wt-remove "$@" ;; list) _wt_run wt-list "$@" ;;