Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/fm-pr-merge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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[@]}"} "$@"
53 changes: 49 additions & 4 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -634,17 +673,23 @@ spawn_send_key() { # <target> <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

Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion tests/fm-tangle-guard.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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).
Expand Down