Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ and versions are tracked in the repo-root `VERSION` file.
- Moved hosted macOS validation to the pinned macOS 15 runner while preserving
the logical macOS 14 compatibility and release-platform contract.

### Fixed

- Reject Git submodule checkouts when validating linked worktree paths.

## [2.2.2] - 2026-10-08

### Documentation
Expand Down
2 changes: 1 addition & 1 deletion base_api_manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ modules:
dependencies: std,str
optional_commands: git,awk,grep,mktemp,rm,sleep
artifacts: lib/bash/git/lib_git.sh,lib/bash/git/README.md,lib/bash/git/tests/lib_git.bats
public_symbols: base_git_branch_merged_to_ref,base_git_branch_upstream,base_git_check_script_up_to_date,base_git_detect_default_branch,base_git_get_current_branch,base_git_list_remote_branches,base_git_list_worktree_branches,base_git_update_repo,base_git_worktree_path_for_branch
public_symbols: base_git_branch_merged_to_ref,base_git_branch_upstream,base_git_check_script_up_to_date,base_git_detect_default_branch,base_git_get_current_branch,base_git_list_remote_branches,base_git_list_worktree_branches,base_git_update_repo,base_git_worktree_path_for_branch,base_git_worktree_path_is_valid
signature_source: lib/bash/git/README.md
inputs: documented per symbol in the module README and API charter
outputs: documented per symbol; named outputs are caller-owned
Expand Down
1 change: 1 addition & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ statuses, and side effects are normative in the linked module README and
- `base_git_list_worktree_branches` — signature: see [`lib/bash/git/README.md`](../lib/bash/git/README.md).
- `base_git_update_repo` — signature: see [`lib/bash/git/README.md`](../lib/bash/git/README.md).
- `base_git_worktree_path_for_branch` — signature: see [`lib/bash/git/README.md`](../lib/bash/git/README.md).
- `base_git_worktree_path_is_valid` — signature: see [`lib/bash/git/README.md`](../lib/bash/git/README.md).

### `gh`

Expand Down
2 changes: 1 addition & 1 deletion docs/v2-api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ signature/effects reference; this table makes coverage auditable.
| std validation/reflection | `base_std_assert_variable_name`, `base_std_assert_indexed_array`, `base_std_assert_associative_array`, `base_std_command_path`, `base_std_function_exists`, `base_std_assert_function_exists`, `base_std_assert_not_null`, `base_std_assert_integer`, `base_std_assert_integer_range`, `base_std_assert_arg_count`, `base_std_assert_command_exists`, `base_std_assert_file_exists`, `base_std_assert_executable`, `base_std_assert_dir_exists` | Predicates return status; explicit `assert_*` APIs are intentional fail-fast precondition checks; named outputs are validated before writes. |
| std miscellaneous | `base_std_safe_cd`, `base_std_safe_unalias`, `base_std_get_my_source_dir`, `base_std_ask_yes_no`, `base_std_wait_for_enter` | `safe_cd` changes `PWD`; source-dir writes one validated output; interactive functions return recoverable EOF/non-TTY statuses. |
| file | `base_file_section_exists`, `base_file_section_needs_update`, `base_file_update_file_section` | Read-only predicates do not mutate; update is idempotent, symlink-preserving, atomic, metadata-preserving, and conflict-aware. |
| git | `base_git_detect_default_branch`, `base_git_worktree_path_for_branch`, `base_git_list_worktree_branches`, `base_git_branch_upstream`, `base_git_branch_merged_to_ref`, `base_git_list_remote_branches`, `base_git_update_repo`, `base_git_get_current_branch`, `base_git_check_script_up_to_date` | Usage and contract errors return `2`; recoverable Git failures and false predicates return `1` unless a function documents a specific status. Read-only inspections use named outputs/stdout as documented; freshness outcomes are `3` dirty, `4` behind, and `5` diverged. |
| git | `base_git_detect_default_branch`, `base_git_worktree_path_for_branch`, `base_git_list_worktree_branches`, `base_git_branch_upstream`, `base_git_branch_merged_to_ref`, `base_git_list_remote_branches`, `base_git_update_repo`, `base_git_get_current_branch`, `base_git_check_script_up_to_date`, `base_git_worktree_path_is_valid` | Usage and contract errors return `2`; recoverable Git failures and false predicates return `1` unless a function documents a specific status. Read-only inspections use named outputs/stdout as documented; freshness outcomes are `3` dirty, `4` behind, and `5` diverged. `base_git_worktree_path_is_valid` returns success only for an existing linked worktree whose resolved Git directory is below the repository's `worktrees/` administrative directory; ordinary repositories, submodules, missing paths, and stale linked-worktree metadata return `1` without diagnostics. |
| gh (stable since `2.0.0`; private process implementation dependency shipped in `2.1.0`) | `base_gh_require_cli`, `base_gh_auth_status_diagnostics`, `base_gh_report_command_failure`, `base_gh_run`, `base_gh_repo_from_remote_url`, `base_gh_infer_repo_from_origin`, `base_gh_repo_default_branch`, `base_gh_api_with_retry` | Usage and contract errors return `2`; recoverable GitHub failures return `1` unless the helper preserves the underlying `gh` status. Diagnostics go stderr; repository/API values use named outputs; retries are bounded and mutation-aware. The public API remains compatible through v2.x; the private process dependency is included in the v2.1.0 artifact. |
| str | `base_str_lower`, `base_str_upper`, `base_str_ltrim`, `base_str_rtrim`, `base_str_trim`, `base_str_contains`, `base_str_starts_with`, `base_str_ends_with`, `base_str_split`, `base_str_join` | String transforms/predicates preserve caller values until validation succeeds; split/join use validated named outputs. |
| arg | `base_arg_parse` | Parses into caller-owned validated arrays/maps and leaves them unchanged on failure. |
Expand Down
2 changes: 1 addition & 1 deletion docs/v2-symbol-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ is part of the caller's intended policy.
| Module | Legacy names | v2 names |
| --- | --- | --- |
| file | `file_section_exists`, `file_section_needs_update`, `update_file_section` | `base_file_section_exists`, `base_file_section_needs_update`, `base_file_update_file_section` |
| git | `git_detect_default_branch`, `git_worktree_path_for_branch`, `git_list_worktree_branches`, `git_branch_upstream`, `git_branch_merged_to_ref`, `git_list_remote_branches`, `git_update_repo`, `git_get_current_branch`, `check_script_up_to_date` | `base_git_detect_default_branch`, `base_git_worktree_path_for_branch`, `base_git_list_worktree_branches`, `base_git_branch_upstream`, `base_git_branch_merged_to_ref`, `base_git_list_remote_branches`, `base_git_update_repo`, `base_git_get_current_branch`, `base_git_check_script_up_to_date` |
| git | `git_detect_default_branch`, `git_worktree_path_for_branch`, `git_list_worktree_branches`, `git_branch_upstream`, `git_branch_merged_to_ref`, `git_list_remote_branches`, `git_update_repo`, `git_get_current_branch`, `check_script_up_to_date` | `base_git_detect_default_branch`, `base_git_worktree_path_for_branch`, `base_git_list_worktree_branches`, `base_git_branch_upstream`, `base_git_branch_merged_to_ref`, `base_git_list_remote_branches`, `base_git_update_repo`, `base_git_get_current_branch`, `base_git_check_script_up_to_date`, `base_git_worktree_path_is_valid` |
| gh | `gh_require_cli`, `gh_auth_status_diagnostics`, `gh_report_command_failure`, `gh_run`, `gh_repo_from_remote_url`, `gh_infer_repo_from_origin`, `gh_repo_default_branch`, `gh_api_with_retry` | `base_gh_require_cli`, `base_gh_auth_status_diagnostics`, `base_gh_report_command_failure`, `base_gh_run`, `base_gh_repo_from_remote_url`, `base_gh_infer_repo_from_origin`, `base_gh_repo_default_branch`, `base_gh_api_with_retry` |
| str | `str_lower`, `str_upper`, `str_ltrim`, `str_rtrim`, `str_trim`, `str_contains`, `str_starts_with`, `str_ends_with`, `str_split`, `str_join` | `base_str_lower`, `base_str_upper`, `base_str_ltrim`, `base_str_rtrim`, `base_str_trim`, `base_str_contains`, `base_str_starts_with`, `base_str_ends_with`, `base_str_split`, `base_str_join` |
| arg | `arg_parse` | `base_arg_parse` |
Expand Down
11 changes: 11 additions & 0 deletions lib/bash/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ Source `lib/bash/std/lib_std.sh` before this library so logging and shared error
- `base_git_worktree_path_for_branch [--result VAR] <branch> [repo]`
Print the worktree path attached to a local branch, or store its exact value
in `VAR` when it can contain trailing newlines.
- `base_git_worktree_path_is_valid <path>`
Return success only when `path` is a valid linked Git worktree. Detached
linked worktrees are valid; ordinary repositories, missing paths, and stale
linked-worktree metadata return `1` without printing Git diagnostics.
- `base_git_list_worktree_branches [repo]`
Print tab-separated worktree path and branch rows. Backslash, tab, newline,
and carriage-return bytes in paths are escaped as `\\`, `\t`, `\n`, and
Expand Down Expand Up @@ -70,6 +74,13 @@ base_git_worktree_path_for_branch --result worktree_path feature/topic "$PWD"
- Worktree helpers parse Git's NUL-delimited porcelain format. Use the lookup
helper's `--result` form whenever a path may end in one or more newlines;
command substitution necessarily strips those bytes from stdout.
- `base_git_worktree_path_is_valid` is intentionally limited to linked
worktrees: it requires a `.git` file and a successful `git -C <path>
rev-parse --is-inside-work-tree` probe whose resolved Git directory is below
the repository's `worktrees/` administrative directory. A `.git` directory
is an ordinary repository checkout, a submodule `.git` file points below
`modules/`, and a `.git` file whose Git administrative record is stale; all
three cases are invalid linked worktrees.
- `base_git_update_repo` only attempts updates when the checked-out branch is the detected default branch, or an explicit expected branch passed by the caller.
- `base_git_update_repo` requires `git_repo` to be the repository root (including
a symlink that resolves to that root); it rejects ordinary descendants rather
Expand Down
25 changes: 25 additions & 0 deletions lib/bash/git/lib_git.sh
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,31 @@ base_git_worktree_path_for_branch() {
fi
}

base_git_worktree_path_is_valid() {
if (($# != 1)); then
base_std_log_error -l base_bash_libs.git "Usage: base_git_worktree_path_is_valid <path>"
return 2
fi

local path="$1"
local git_dir common_dir git_dir_abs common_dir_abs worktrees_dir

[[ -d "$path" && -f "$path/.git" ]] || return 1
git -C "$path" rev-parse --is-inside-work-tree > /dev/null 2>&1 || return 1

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: submodule checkouts misclassified as valid linked worktrees

A Git submodule's checked-out directory also has .git as a regular file (a gitlink, e.g. gitdir: ../.git/modules/<name>), and git -C <submodule_path> rev-parse --is-inside-work-tree also returns true/exit 0 for it, exactly as for a real linked worktree. So base_git_worktree_path_is_valid <submodule_path> returns 0 (success) for a submodule, even though a submodule is not a linked worktree.

Verified empirically:

$ git submodule add ../inner sub   # inside a repo
$ cat sub/.git
gitdir: ../.git/modules/sub
$ git -C sub rev-parse --is-inside-work-tree
true

Both guard clauses in the new function pass for sub, so it validates as a linked worktree.

This contradicts the documented contract (README.md / docs/v2-api-contract.md): "Return success only when path is a valid linked Git worktree ... ordinary repositories ... return 1" — a submodule is neither an ordinary repository checkout nor a linked worktree, but isn't excluded by either check. Per #579 / basefoundry/base#2621, this predicate is meant to gate "dirty checks and removal" of worktree-like directories in downstream consumers (Base), so a false positive here is a real correctness gap, not just a doc nuance. Consider adding a submodule-exclusion check, e.g. verifying the .git file's gitdir: target resolves under <repo>/.git/worktrees/ rather than .git/modules/.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 857df0d. base_git_worktree_path_is_valid now resolves --git-dir and --git-common-dir and accepts only paths below the repository worktrees/ administrative directory, so submodules under .git/modules/ return status 1. Added a real submodule regression fixture and updated the README, v2 API contract, and changelog. Focused BATS passes 79/79 and the full ./tests/validate.sh gate passes 715/715 plus the repository release and quality contracts.


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
git_dir_abs="$(cd -- "$git_dir" 2> /dev/null && pwd -P)" || return 1
common_dir_abs="$(cd -- "$common_dir" 2> /dev/null && pwd -P)" || return 1
worktrees_dir="$common_dir_abs/worktrees"

[[ -d "$worktrees_dir" ]] || return 1
case "$git_dir_abs" in
"$worktrees_dir"/*) return 0 ;;
*) return 1 ;;
esac
}

base_git_list_worktree_branches() {
if (($# > 1)); then
base_std_log_error -l base_bash_libs.git "Usage: base_git_list_worktree_branches [repo_dir]"
Expand Down
53 changes: 53 additions & 0 deletions lib/bash/git/tests/lib_git.bats
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ setup() {
base_git_branch_merged_to_ref \
base_git_update_repo \
base_git_get_current_branch \
base_git_worktree_path_is_valid \
base_git_check_script_up_to_date; do
bats_run "$BASH" -c '
mode="$1"
Expand Down Expand Up @@ -205,6 +206,58 @@ setup() {
unset -f git
}

@test "base_git_worktree_path_is_valid accepts linked worktrees and rejects other paths" {
local repo="$TEST_TMPDIR/repo"
local linked="$TEST_TMPDIR/linked worktree"
local detached="$TEST_TMPDIR/detached worktree"
local clone="$TEST_TMPDIR/clone"
local stale="$TEST_TMPDIR/stale worktree"

init_git_repo "$repo"
printf 'base\n' > "$repo/data.txt"
commit_all "$repo" "Initial commit"
git -C "$repo" worktree add -b feature "$linked" >/dev/null 2>&1
git -C "$repo" worktree add --detach "$detached" HEAD >/dev/null 2>&1
git clone --quiet "$repo" "$clone"
mkdir -p "$stale"
printf 'gitdir: %s\n' "$TEST_TMPDIR/missing-worktree-admin" > "$stale/.git"

base_git_worktree_path_is_valid "$linked"
base_git_worktree_path_is_valid "$detached"
! base_git_worktree_path_is_valid "$repo"
! base_git_worktree_path_is_valid "$clone"
! base_git_worktree_path_is_valid "$stale"
! base_git_worktree_path_is_valid "$TEST_TMPDIR/missing"
}

@test "base_git_worktree_path_is_valid rejects submodule checkouts" {
local source_repo="$TEST_TMPDIR/submodule-source"
local repo="$TEST_TMPDIR/repo"

init_git_repo "$source_repo"
printf 'submodule\n' > "$source_repo/data.txt"
commit_all "$source_repo" "Initial submodule commit"

init_git_repo "$repo"
printf 'superproject\n' > "$repo/data.txt"
commit_all "$repo" "Initial superproject commit"
git -C "$repo" -c protocol.file.allow=always submodule add "$source_repo" submodule >/dev/null 2>&1

! base_git_worktree_path_is_valid "$repo/submodule"
}

@test "base_git_worktree_path_is_valid suppresses stale metadata diagnostics" {
local stale="$TEST_TMPDIR/stale-worktree"

mkdir -p "$stale"
printf 'gitdir: %s\n' "$TEST_TMPDIR/missing-worktree-admin" > "$stale/.git"

capture_command base_git_worktree_path_is_valid "$stale"

[ "$status" -eq 1 ]
[ -z "$output" ]
}

@test "git worktree helpers parse canonical porcelain output" {
git() {
if [[ "${1:-}" == "worktree" || "${3:-}" == "worktree" ]]; then
Expand Down
25 changes: 21 additions & 4 deletions scripts/api-manifest
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ release_module_check() {
check_release_tree() {
local manifest="$1"
local release_ref="$2"
local symbol
local symbol release_manifest="$manifest" historical_manifest=""
local -a fields=()
local -A release_module_sources=()
local -A release_module_docs=()
Expand All @@ -569,18 +569,35 @@ check_release_tree() {
error "release reference is not a local commit: $release_ref"
return 1
}
if [[ "$manifest" == "$default_manifest" ]]; then
historical_manifest="$(mktemp "${TMPDIR:-/tmp}/base-api-release-manifest.XXXXXX")" || return 1
if ! git -C "$repo_root" show "$release_ref:base_api_manifest.yaml" > "$historical_manifest" 2> /dev/null; then
rm -f "$historical_manifest"
error "release reference '$release_ref' does not contain base_api_manifest.yaml"
return 1
fi

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup: duplicated historical-manifest-loading boilerplate

The new block here (mktemp + git -C "$repo_root" show "$release_ref:base_api_manifest.yaml" + error handling) is near-verbatim identical to the existing block in check_stable_compatibility() a hundred lines above (same git show, same error message, same cleanup-on-failure pattern; only the mktemp template suffix differs: base-api-release-manifest vs base-api-historical). Consider factoring this into a shared load_historical_manifest() helper that both check_stable_compatibility and check_release_tree call, to avoid maintaining the same git-show/error/cleanup logic in two places.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred as intentionally non-blocking cleanup for this PR. The duplicated historical-manifest loading paths have identical behavior today, while extracting a helper would widen the diff without changing the release contract. The correctness fix and its regression coverage remain the merge prerequisite; helper extraction can be tracked separately if desired.

# The compatibility check above verifies that every historical stable
# symbol remains available. The release tree check must inspect the
# historical manifest so additive stable APIs are allowed in a minor
# v2 release without pretending they were present in v2.0.0.
release_manifest="$historical_manifest"
fi
while IFS=$'\t' read -r -a fields; do
[[ -n "${fields[0]-}" ]] || continue
release_module_sources["${fields[0]}"]="${fields[2]}"
release_module_docs["${fields[0]}"]="${fields[3]}"
release_module_tests["${fields[0]}"]="${fields[4]}"
release_module_symbols["${fields[0]}"]="${fields[8]}"
release_module_dependencies["${fields[0]}"]="${fields[5]}"
done < <(manifest_rows "$manifest")
done < <(manifest_rows "$release_manifest")
while IFS=$'\t' read -r -a fields; do
[[ -n "${fields[0]-}" && "${fields[14]}" == stable ]] || continue
release_module_check "${fields[0]}" "${fields[0]}" || return 1
done < <(manifest_rows "$manifest")
release_module_check "${fields[0]}" "${fields[0]}" || {
rm -f "$historical_manifest"
return 1
}
done < <(manifest_rows "$release_manifest")
rm -f "$historical_manifest"
printf 'Stable API release reference is valid: %s\n' "$release_ref"
}

Expand Down
Loading