-
Notifications
You must be signed in to change notification settings - Fork 5.8k
feat: Add Git worktree-aware workflows #1547
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
938f91e
7fd1c4f
1eebb39
9bfa6c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -78,6 +78,30 @@ check_feature_branch() { | |||||||||||||||||||||
| return 1 | ||||||||||||||||||||||
| fi | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # Feature 001: Worktree/branch alignment validation | ||||||||||||||||||||||
| # Check if we're in a worktree and validate directory/branch name alignment | ||||||||||||||||||||||
| if [[ -f .git ]]; then | ||||||||||||||||||||||
| # We're in a worktree (.git is a file, not a directory) | ||||||||||||||||||||||
| local worktree_dir=$(basename "$(pwd)") | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
Comment on lines
+85
to
+86
|
||||||||||||||||||||||
| local worktree_dir=$(basename "$(pwd)") | |
| local worktree_root | |
| local worktree_dir | |
| if worktree_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then | |
| worktree_dir="$(basename "$worktree_root")" | |
| else | |
| # Fallback: use current directory name if git metadata is unavailable | |
| worktree_dir="$(basename "$(pwd)")" | |
| fi |
askpatrickw marked this conversation as resolved.
Show resolved
Hide resolved
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -160,10 +160,20 @@ clean_branch_name() { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # were initialised with --no-git. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if git rev-parse --show-toplevel >/dev/null 2>&1; then | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| REPO_ROOT=$(git rev-parse --show-toplevel) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Check if we're in any Git repository (works for both bare and non-bare repos) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if git rev-parse --git-dir >/dev/null 2>&1; then | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| HAS_GIT=true | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Check if this is a bare repository | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if [ "$(git rev-parse --is-bare-repository 2>/dev/null)" = "true" ]; then | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Bare repository - use git-dir as root | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| REPO_ROOT=$(git rev-parse --git-dir) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| REPO_ROOT=$(cd "$REPO_ROOT" && pwd) # Resolve to absolute path | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Non-bare repository - use show-toplevel | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| REPO_ROOT=$(git rev-parse --show-toplevel) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Not a Git repository - fall back to marker search | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| REPO_ROOT="$(find_repo_root "$SCRIPT_DIR")" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if [ -z "$REPO_ROOT" ]; then | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| echo "Error: Could not determine repository root. Please run this script from within the repository." >&2 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -174,9 +184,28 @@ fi | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cd "$REPO_ROOT" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Save original directory for error recovery | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ORIGINAL_DIR="$PWD" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SPECS_DIR="$REPO_ROOT/specs" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mkdir -p "$SPECS_DIR" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Feature 001: Read source management mode from config | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| CONFIG_FILE="$REPO_ROOT/.specify/memory/config.json" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SOURCE_MODE="branch" # Default to branch mode for backward compatibility | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| WORKTREE_FOLDER="" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if [ -f "$CONFIG_FILE" ]; then | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Parse JSON config using grep/sed (no jq dependency needed) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SOURCE_MODE=$(grep -o '"source_management_flow"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_FILE" | sed 's/.*"\([^"]*\)".*/\1/' || echo "branch") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| WORKTREE_FOLDER=$(grep -o '"worktree_folder"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_FILE" | sed 's/.*"\([^"]*\)".*/\1/' || echo "") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Fallback to branch mode if config is invalid | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if [ -z "$SOURCE_MODE" ]; then | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+199
to
+205
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Parse JSON config using grep/sed (no jq dependency needed) | |
| SOURCE_MODE=$(grep -o '"source_management_flow"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_FILE" | sed 's/.*"\([^"]*\)".*/\1/' || echo "branch") | |
| WORKTREE_FOLDER=$(grep -o '"worktree_folder"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_FILE" | sed 's/.*"\([^"]*\)".*/\1/' || echo "") | |
| fi | |
| # Fallback to branch mode if config is invalid | |
| if [ -z "$SOURCE_MODE" ]; then | |
| # Parse JSON config using python's json module for robustness | |
| if CONFIG_VALUES=$(python3 - <<'PY' "$CONFIG_FILE" 2>/dev/null | |
| import json | |
| import sys | |
| path = sys.argv[1] | |
| try: | |
| with open(path, 'r', encoding='utf-8') as f: | |
| data = json.load(f) | |
| except Exception: | |
| # On any parsing/IO error, fall back to defaults handled in shell | |
| print("branch") | |
| print("") | |
| sys.exit(0) | |
| source_mode = data.get("source_management_flow", "branch") | |
| worktree_folder = data.get("worktree_folder", "") | |
| # Ensure we always print exactly two lines | |
| print(source_mode if isinstance(source_mode, str) else "branch") | |
| print(worktree_folder if isinstance(worktree_folder, str) else "") | |
| PY | |
| ); then | |
| SOURCE_MODE=$(printf '%s\n' "$CONFIG_VALUES" | sed -n '1p') | |
| WORKTREE_FOLDER=$(printf '%s\n' "$CONFIG_VALUES" | sed -n '2p') | |
| fi | |
| fi | |
| # Fallback to branch mode if config is invalid or unexpected | |
| if [ -z "$SOURCE_MODE" ] || { [ "$SOURCE_MODE" != "branch" ] && [ "$SOURCE_MODE" != "worktree" ] && [ "$SOURCE_MODE" != "none" ]; }; then |
Copilot
AI
Feb 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The worktree folder path is resolved relative to REPO_ROOT without validating that the resulting path doesn't escape the repository. A malicious config file could set worktree_folder to something like "../../../../tmp/worktrees" which would create worktrees outside the repository.
Consider validating that the resolved absolute path is still within or explicitly outside the repository with user awareness, or restrict worktree_folder to relative paths only that don't contain path traversal sequences.
| # Resolve to absolute path | |
| if [[ "$WORKTREE_FOLDER" != /* ]]; then | |
| WORKTREE_FOLDER="$REPO_ROOT/$WORKTREE_FOLDER" | |
| fi | |
| # Ensure REPO_ROOT is absolute and canonical for comparisons | |
| REPO_ROOT_ABS="$(cd "$REPO_ROOT" >/dev/null 2>&1 && pwd)" | |
| # Normalize and validate worktree folder path | |
| if [[ "$WORKTREE_FOLDER" != /* ]]; then | |
| # Relative path: forbid path traversal via ".." segments | |
| case "$WORKTREE_FOLDER" in | |
| ".." | "../"* | *"/../"* | *"/.." ) | |
| >&2 echo "[specify] Error: Relative worktree folder must not contain '..' path traversal segments: $WORKTREE_FOLDER" | |
| exit 1 | |
| ;; | |
| esac | |
| if command -v realpath >/dev/null 2>&1; then | |
| WORKTREE_FOLDER="$(realpath -m "$REPO_ROOT_ABS/$WORKTREE_FOLDER")" | |
| else | |
| WORKTREE_FOLDER="$REPO_ROOT_ABS/$WORKTREE_FOLDER" | |
| fi | |
| else | |
| # Absolute path: canonicalize if possible | |
| if command -v realpath >/dev/null 2>&1; then | |
| WORKTREE_FOLDER="$(realpath -m "$WORKTREE_FOLDER")" | |
| fi | |
| fi | |
| # If realpath is available and worktree folder is outside repo root, require explicit confirmation | |
| if command -v realpath >/dev/null 2>&1; then | |
| if [[ "$WORKTREE_FOLDER" != "$REPO_ROOT_ABS" && "$WORKTREE_FOLDER" != "$REPO_ROOT_ABS/"* ]]; then | |
| >&2 echo "[specify] Warning: Worktree folder is outside the repository root:" | |
| >&2 echo "[specify] Repo root: $REPO_ROOT_ABS" | |
| >&2 echo "[specify] Worktree: $WORKTREE_FOLDER" | |
| read -p "Continue using an external worktree folder? (y/N): " -n 1 -r ALLOW_EXTERNAL_WORKTREE | |
| echo | |
| if [[ ! $ALLOW_EXTERNAL_WORKTREE =~ ^[Yy]$ ]]; then | |
| >&2 echo "[specify] Aborting: external worktree folder not confirmed" | |
| exit 1 | |
| fi | |
| fi | |
| fi |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is valid (very common) for worktrees to be outside (../myworktree) the bare repo. We could warn on any traversal, or fail on any more than one level of traversal, but I fear that's being too restrictive.
askpatrickw marked this conversation as resolved.
Show resolved
Hide resolved
Copilot
AI
Feb 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When git worktree add -b fails after creating the branch but before completing the worktree setup (e.g., due to filesystem issues), the branch may be left dangling without a corresponding worktree. The error handler at line 356-358 doesn't attempt to clean up the branch in such cases.
Consider adding cleanup logic to remove the newly created branch if worktree creation fails, or at least document this potential state in the error message.
| # Create worktree with new branch | |
| git worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" || { | |
| >&2 echo "[specify] Error: Failed to create worktree" | |
| exit 1 | |
| # Track whether the branch existed before creating the worktree | |
| if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then | |
| BRANCH_ALREADY_EXISTS=true | |
| else | |
| BRANCH_ALREADY_EXISTS=false | |
| fi | |
| # Create worktree with new branch | |
| git worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" || { | |
| worktree_status=$? | |
| >&2 echo "[specify] Error: Failed to create worktree at: $WORKTREE_PATH" | |
| # If the branch did not exist before, but exists now, attempt cleanup | |
| if [ "$BRANCH_ALREADY_EXISTS" = "false" ]; then | |
| if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then | |
| >&2 echo "[specify] Attempting to delete dangling branch '$BRANCH_NAME' created during failed worktree setup..." | |
| if ! git branch -D "$BRANCH_NAME" >/dev/null 2>&1; then | |
| >&2 echo "[specify] Warning: Failed to delete dangling branch '$BRANCH_NAME'." | |
| >&2 echo "[specify] You may need to delete it manually: git branch -D \"$BRANCH_NAME\"" | |
| fi | |
| fi | |
| fi | |
| exit "$worktree_status" |
Copilot
AI
Feb 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error message "Failed to create worktree" is too generic. If the branch name already exists, Git will return a specific error, but this handler suppresses that detail. Users won't know whether the failure is due to:
- Branch name already exists
- Filesystem/permission issues
- Invalid Git state
- Other Git errors
Consider capturing and displaying the actual Git error message to help users diagnose the problem.
| git worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" || { | |
| >&2 echo "[specify] Error: Failed to create worktree" | |
| exit 1 | |
| } | |
| if ! output=$(git worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" 2>&1); then | |
| >&2 echo "[specify] Error: Failed to create worktree" | |
| >&2 echo "[specify] Git reported:" | |
| >&2 echo "$output" | |
| exit 1 | |
| fi | |
| # Re-emit git output on success (if any) | |
| if [ -n "$output" ]; then | |
| echo "$output" | |
| fi |
Copilot
AI
Feb 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This line appears to be redundant. The code only reaches this point in worktree mode if Git is available (checked at line 303), and REPO_ROOT has already been updated to WORKTREE_PATH at line 371. So this line is just setting SPECS_DIR to $WORKTREE_PATH/specs, which is already the correct value. The condition at line 392 should never be false when this code is reached in worktree mode, making this update unnecessary.
| # Update SPECS_DIR if we're in a worktree | |
| if [ "$SOURCE_MODE" = "worktree" ] && [ "$HAS_GIT" = true ]; then | |
| SPECS_DIR="$REPO_ROOT/specs" | |
| fi |
Copilot
AI
Feb 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After creating a worktree and changing directory to it, REPO_ROOT is updated to the worktree path (line 371). This causes the template lookup at line 403 to search for .specify/templates/spec-template.md inside the worktree directory rather than the main repository. Since .specify/ doesn't exist in worktrees, the template will never be found, and an empty spec.md file will be created instead.
Consider preserving the original repository root in a separate variable (e.g., MAIN_REPO_ROOT) so that the template can be found regardless of the current working directory.
| TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md" | |
| # Use the main repository root when resolving the template path so that | |
| # worktree mode still finds the shared .specify directory in the main repo. | |
| if [ "$SOURCE_MODE" = "worktree" ] && [ "$HAS_GIT" = true ]; then | |
| MAIN_REPO_ROOT="$ORIGINAL_DIR" | |
| else | |
| MAIN_REPO_ROOT="$REPO_ROOT" | |
| fi | |
| TEMPLATE="$MAIN_REPO_ROOT/.specify/templates/spec-template.md" |
Uh oh!
There was an error while loading. Please reload this page.