diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 8ffb32cc..13e48230 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -87,4 +87,4 @@ if ! caller_has_merge_method "$@"; then merge_args=(--squash) fi -gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]}" "$@" +gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" ${merge_args[@]+"${merge_args[@]}"} "$@" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 1c4352a6..4c50df11 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -415,6 +415,45 @@ path_is_ancestor_of() { return 1 } +# Resolve a git worktree's common dir to an absolute real path. `git rev-parse +# --git-common-dir` may return a path relative to the worktree, so anchor a +# relative result to the worktree before canonicalizing with pwd -P; this lets +# two worktrees of the same repository be compared regardless of symlinked paths. +git_common_dir_real() { + local dir=$1 common + common=$(git -C "$dir" rev-parse --git-common-dir 2>/dev/null) || return 1 + [ -n "$common" ] || return 1 + case "$common" in + /*) ;; + *) common="$dir/$common" ;; + esac + [ -d "$common" ] || return 1 + (cd "$common" && pwd -P) +} + +# A captured pane path is the genuine treehouse lease only when it is a worktree +# of the SAME repository as the project, resolved distinctly from it. The wait +# loop below accepts the first pane cwd that differs from the project dir, but +# shell-init noise (e.g. a transient ~/.oh-my-zsh cwd during zsh startup - itself +# a git repo, but a DIFFERENT repository) can appear before `treehouse get` +# lands. Comparing canonical git common dirs distinguishes the real lease from +# such noise no matter where treehouse.toml places the pool root, and resolving +# every path with pwd -P keeps a symlinked $HOME from breaking the match. The +# isolation guard after the loop is the backstop; this keeps the loop from ever +# selecting the wrong path. +is_project_worktree() { + local candidate=$1 proj_abs=$2 cand_real proj_real cand_common proj_common + [ -d "$candidate" ] || return 1 + [ "$(git -C "$candidate" rev-parse --is-inside-work-tree 2>/dev/null)" = "true" ] || return 1 + cand_common=$(git_common_dir_real "$candidate") || return 1 + proj_common=$(git_common_dir_real "$proj_abs") || return 1 + [ "$cand_common" = "$proj_common" ] || return 1 + cand_real=$(cd "$candidate" 2>/dev/null && pwd -P) || return 1 + proj_real=$(cd "$proj_abs" 2>/dev/null && pwd -P) || return 1 + [ "$cand_real" != "$proj_real" ] || return 1 + return 0 +} + validate_firstmate_home_for_spawn() { local id=$1 home=$2 abs_home abs_active_home abs_root marker_id abs_home=$(resolved_existing_dir "$home") || return 1 @@ -634,17 +673,23 @@ spawn_send_key() { # if [ "$KIND" != secondmate ]; then spawn_send_text_line "$T" 'treehouse get' - # Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree. - for _ in $(seq 1 60); do + # Wait for the treehouse subshell: the pane's cwd moves from the project to the + # worktree. Only accept a captured path that is genuinely a worktree of the SAME + # repository as the project - a bare "differs from the project dir" check once + # recorded shell-init noise (e.g. a transient ~/.oh-my-zsh cwd during zsh startup) + # that appears before `treehouse get` lands. This needs no knowledge of the + # treehouse pool root, so it works wherever treehouse.toml places it. + wt_wait="${FM_SPAWN_WORKTREE_WAIT:-60}" + for _ in $(seq 1 "$wt_wait"); do p=$(spawn_current_path "$T" || true) - if [ -n "$p" ] && [ "$p" != "$PROJ_ABS" ]; then + if [ -n "$p" ] && is_project_worktree "$p" "$PROJ_ABS"; then WT="$p" break fi sleep 1 done if [ -z "$WT" ]; then - echo "error: treehouse get did not enter a worktree within 60s; inspect window $T" >&2 + echo "error: treehouse get did not enter a worktree within ${wt_wait}s; inspect window $T" >&2 exit 1 fi diff --git a/docs/configuration.md b/docs/configuration.md index 5649c7a6..310c0068 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -213,6 +213,7 @@ FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream FM_BUSY_REGEX='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel' # busy-pane signatures, shared by watcher, fm-crew-state pane fallback, and tmux helper FM_COMPOSER_IDLE_RE= # optional empty-composer regex, applied after dim-ghost and border stripping GROK_HOME= # optional Grok config home for firstmate's global grok turn-end hook; defaults to ~/.grok +FM_SPAWN_WORKTREE_WAIT=60 # seconds fm-spawn waits for treehouse get to land the pane in a same-repo isolated worktree before aborting FM_SEND_RETRIES=3 # fm-send Enter-retry attempts after typing the line once FM_SEND_SLEEP=0.4 # seconds between fm-send submit checks FM_SEND_SETTLE=1 # seconds fm-send waits after a successful text submit; 0 disables diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index 92a697d8..001d3566 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -181,6 +181,7 @@ run_spawn() { FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$pane" TMUX="fake,1,0" \ + FM_SPAWN_WORKTREE_WAIT=2 \ PATH="$fakebin:$PATH" \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" codex 2>&1 } @@ -196,9 +197,12 @@ test_spawn_isolation_abort() { mkdir -p "$TMP_ROOT/spawn-notgit" "$proj/sub" # Abort: the pane resolves to a plain non-git directory (not a worktree at all). + # The wait loop only captures a same-repo worktree, so a persistent non-git pane + # is treated as "treehouse never landed" and times out rather than reaching the + # isolation guard - still a clean abort (exit 1, no meta recorded). out=$(run_spawn "$home" abort-notgit-dd4 "$proj" "$TMP_ROOT/spawn-notgit" "$fakebin"); status=$? expect_code 1 "$status" "spawn into a non-worktree dir should abort" - assert_contains "$out" "did not yield an isolated worktree" "non-worktree spawn lacked the isolation error" + assert_contains "$out" "did not enter a worktree" "non-worktree spawn lacked the timeout abort" assert_absent "$home/state/abort-notgit-dd4.meta" "aborted spawn must not record meta" # Abort: the pane resolves INTO the primary checkout (a subdir of PROJ_ABS).