aidevops 3.13.76 → 3.13.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,647 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-License-Identifier: MIT
3
+ # SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
4
+ # =============================================================================
5
+ # aidevops Repo Management Library
6
+ # =============================================================================
7
+ # Repository registration, discovery, and validation functions extracted from
8
+ # aidevops.sh to keep the orchestrator under the 2000-line file-size threshold.
9
+ #
10
+ # Covers:
11
+ # 1. init_repos_file / get_repo_slug / register_repo / get_registered_repos
12
+ # 2. Repo defaults, scope, mission-control resolution
13
+ # 3. Planning file checks, protected-branch validation
14
+ #
15
+ # Usage: source "${SCRIPT_DIR}/aidevops-repos-lib.sh"
16
+ #
17
+ # Dependencies:
18
+ # - INSTALL_DIR, AGENTS_DIR, CONFIG_DIR, REPOS_FILE (set by aidevops.sh)
19
+ # - print_* helpers and utility functions (defined in aidevops.sh before sourcing)
20
+ #
21
+ # Part of aidevops framework: https://aidevops.sh
22
+
23
+ # Apply strict mode only when executed directly (not when sourced)
24
+ [[ "${BASH_SOURCE[0]}" == "${0}" ]] && set -euo pipefail
25
+
26
+ # Include guard
27
+ [[ -n "${_AIDEVOPS_REPOS_LIB_LOADED:-}" ]] && return 0
28
+ _AIDEVOPS_REPOS_LIB_LOADED=1
29
+
30
+
31
+ # Initialize repos.json if it doesn't exist
32
+ init_repos_file() {
33
+ if [[ ! -f "$REPOS_FILE" ]]; then
34
+ mkdir -p "$CONFIG_DIR"
35
+ echo '{"initialized_repos": [], "git_parent_dirs": ["~/Git"]}' >"$REPOS_FILE"
36
+ elif command -v jq &>/dev/null; then
37
+ # Migrate: add git_parent_dirs if missing from existing repos.json
38
+ if ! jq -e '.git_parent_dirs' "$REPOS_FILE" &>/dev/null; then
39
+ local temp_file="${REPOS_FILE}.tmp"
40
+ if jq '. + {"git_parent_dirs": ["~/Git"]}' "$REPOS_FILE" >"$temp_file"; then
41
+ mv "$temp_file" "$REPOS_FILE"
42
+ else
43
+ rm -f "$temp_file"
44
+ fi
45
+ fi
46
+ # Migrate: backfill slug for entries missing it (detect from git remote)
47
+ local needs_slug
48
+ needs_slug=$(jq '[.initialized_repos[] | select(.slug == null or .slug == "")] | length' "$REPOS_FILE" 2>/dev/null) || needs_slug="0"
49
+ if [[ "$needs_slug" -gt 0 ]]; then
50
+ local temp_file="${REPOS_FILE}.tmp"
51
+ local repo_path slug
52
+ # Build a map of path->slug for repos missing slugs
53
+ while IFS= read -r repo_path; do
54
+ # Expand ~ to $HOME for git operations
55
+ local expanded_path="${repo_path/#\~/$HOME}"
56
+ slug=$(get_repo_slug "$expanded_path" 2>/dev/null) || slug=""
57
+ if [[ -n "$slug" ]]; then
58
+ jq --arg path "$repo_path" --arg slug "$slug" \
59
+ '(.initialized_repos[] | select(.path == $path and (.slug == null or .slug == ""))) |= . + {slug: $slug}' \
60
+ "$REPOS_FILE" >"$temp_file" && mv "$temp_file" "$REPOS_FILE"
61
+ fi
62
+ done < <(jq -r '.initialized_repos[] | select(.slug == null or .slug == "") | .path' "$REPOS_FILE" 2>/dev/null)
63
+ fi
64
+ fi
65
+ return 0
66
+ }
67
+
68
+ # Detect GitHub slug (owner/repo) from git remote origin
69
+ # Usage: get_repo_slug <path>
70
+ get_repo_slug() {
71
+ local repo_path="$1"
72
+ local remote_url
73
+ remote_url=$(git -C "$repo_path" remote get-url origin 2>/dev/null) || return 1
74
+ # Strip protocol/host prefix and .git suffix to get owner/repo
75
+ local slug
76
+ slug=$(echo "$remote_url" | sed 's|.*github\.com[:/]||;s|\.git$||')
77
+ if [[ -n "$slug" && "$slug" == *"/"* ]]; then
78
+ echo "$slug"
79
+ return 0
80
+ fi
81
+ return 1
82
+ }
83
+
84
+ # Check whether a repo name follows mission-control naming.
85
+ # Usage: _is_mission_control_repo_name <repo-name>
86
+ _is_mission_control_repo_name() {
87
+ local repo_name="$1"
88
+ case "$repo_name" in
89
+ mission-control | *-mission-control | mission-control-*)
90
+ return 0
91
+ ;;
92
+ *)
93
+ return 1
94
+ ;;
95
+ esac
96
+ }
97
+
98
+ # Resolve mission-control scope from slug and current actor.
99
+ # Usage: _resolve_mission_control_scope <owner/repo> <current-login>
100
+ # Prints: personal | org (or empty if not mission-control)
101
+ _resolve_mission_control_scope() {
102
+ local slug="$1"
103
+ local current_login="$2"
104
+
105
+ if [[ -z "$slug" ]] || [[ "$slug" != */* ]]; then
106
+ echo ""
107
+ return 1
108
+ fi
109
+
110
+ local owner repo
111
+ owner="${slug%%/*}"
112
+ repo="${slug##*/}"
113
+
114
+ if ! _is_mission_control_repo_name "$repo"; then
115
+ echo ""
116
+ return 1
117
+ fi
118
+
119
+ if [[ -n "$current_login" && "$owner" == "$current_login" ]]; then
120
+ echo "personal"
121
+ return 0
122
+ fi
123
+
124
+ echo "org"
125
+ return 0
126
+ }
127
+
128
+ # Compute default repos.json registration values.
129
+ # Usage: _compute_repo_registration_defaults <path> <slug> <local_only> <maintainer>
130
+ # Prints eval-safe key=value lines: DEFAULT_PULSE, DEFAULT_PRIORITY
131
+ _compute_repo_registration_defaults() {
132
+ local repo_path="$1"
133
+ local slug="$2"
134
+ local is_local_only="$3"
135
+ local maintainer="$4"
136
+
137
+ local default_pulse=false
138
+ local default_priority=""
139
+
140
+ if [[ "$is_local_only" == "true" ]]; then
141
+ default_pulse=false
142
+ else
143
+ default_pulse=true
144
+ fi
145
+
146
+ if [[ "$slug" == */* ]]; then
147
+ local owner repo
148
+ owner="${slug%%/*}"
149
+ repo="${slug##*/}"
150
+
151
+ if [[ "$repo" == "$owner" ]] && [[ "$repo_path" == "$HOME/Git/$owner" ]]; then
152
+ default_pulse=false
153
+ default_priority="profile"
154
+ elif _is_mission_control_repo_name "$repo"; then
155
+ default_pulse=true
156
+ if [[ "$owner" == "$maintainer" ]]; then
157
+ default_priority="product"
158
+ else
159
+ default_priority="tooling"
160
+ fi
161
+ fi
162
+ fi
163
+
164
+ printf 'DEFAULT_PULSE=%q\n' "$default_pulse"
165
+ printf 'DEFAULT_PRIORITY=%q\n' "$default_priority"
166
+ return 0
167
+ }
168
+
169
+ # Infer the init_scope for a repo when not explicitly set.
170
+ # Priority: .aidevops.json > repos.json entry > context inference.
171
+ # Returns one of: minimal, standard, public
172
+ # Usage: _infer_init_scope <project_root> [is_local_only]
173
+ # Pass is_local_only="true" when the caller already has it to avoid redundant I/O.
174
+ _infer_init_scope() {
175
+ local project_root="$1"
176
+ local is_local_only="${2:-}"
177
+
178
+ # 1. Check .aidevops.json
179
+ if [[ -f "$project_root/.aidevops.json" ]]; then
180
+ local json_scope
181
+ json_scope=$(jq -r '.init_scope // empty' "$project_root/.aidevops.json" 2>/dev/null || echo "")
182
+ if [[ -n "$json_scope" ]]; then
183
+ echo "$json_scope"
184
+ return 0
185
+ fi
186
+ fi
187
+
188
+ # 2. Check repos.json entry — single jq pass reads both init_scope and local_only
189
+ if command -v jq &>/dev/null && [[ -f "${REPOS_FILE:-$HOME/.config/aidevops/repos.json}" ]]; then
190
+ local repos_file="${REPOS_FILE:-$HOME/.config/aidevops/repos.json}"
191
+ local canonical_path
192
+ canonical_path=$(cd "$project_root" 2>/dev/null && pwd -P) || canonical_path="$project_root"
193
+ local repo_data
194
+ repo_data=$(jq -r --arg path "$canonical_path" \
195
+ '.initialized_repos[] | select(.path == $path) | "\(.init_scope // "")|\(.local_only // "false")"' \
196
+ "$repos_file" 2>/dev/null | head -n 1 || echo "")
197
+ if [[ -n "$repo_data" ]]; then
198
+ local repo_scope="${repo_data%|*}"
199
+ local repo_local="${repo_data#*|}"
200
+ if [[ -n "$repo_scope" ]]; then
201
+ echo "$repo_scope"
202
+ return 0
203
+ fi
204
+ # Repo found but no explicit scope — pick up local_only for context inference below
205
+ [[ -z "$is_local_only" ]] && is_local_only="$repo_local"
206
+ fi
207
+ fi
208
+
209
+ # 3. Context inference
210
+ # Use pre-computed is_local_only when available; fall back to git remote check
211
+ if [[ "$is_local_only" == "true" ]]; then
212
+ echo "minimal"
213
+ return 0
214
+ fi
215
+
216
+ if ! git -C "$project_root" remote get-url origin &>/dev/null 2>&1; then
217
+ echo "minimal"
218
+ return 0
219
+ fi
220
+
221
+ # Default: standard (backward compatible)
222
+ echo "standard"
223
+ return 0
224
+ }
225
+
226
+ # Check whether a given scope level includes a feature tier.
227
+ # Scope hierarchy: minimal < standard < public
228
+ # Usage: _scope_includes <current_scope> <required_level>
229
+ # Returns 0 (true) if current_scope >= required_level, 1 (false) otherwise.
230
+ _scope_includes() {
231
+ local current="$1"
232
+ local required="$2"
233
+
234
+ # Map scope to numeric level
235
+ local current_level=0 required_level=0
236
+ case "$current" in
237
+ minimal) current_level=0 ;;
238
+ standard) current_level=1 ;;
239
+ public) current_level=2 ;;
240
+ *) current_level=1 ;; # unknown defaults to standard
241
+ esac
242
+ case "$required" in
243
+ minimal) required_level=0 ;;
244
+ standard) required_level=1 ;;
245
+ public) required_level=2 ;;
246
+ *) required_level=1 ;;
247
+ esac
248
+
249
+ [[ $current_level -ge $required_level ]]
250
+ }
251
+
252
+ # Resolve a worktree path to its canonical main-worktree path, if applicable.
253
+ # Usage: resolve_canonical_repo_path <path>
254
+ # Prints the canonical path to stdout. If the input is already the main
255
+ # worktree, a non-git path, or git is unavailable, prints the input unchanged.
256
+ #
257
+ # Why this exists: `find ~/Git -name .aidevops.json` in auto-discovery and
258
+ # similar scans pick up .aidevops.json files that exist in linked worktrees
259
+ # (because worktrees inherit the working tree contents), and without this
260
+ # guard each worktree gets registered as a separate repo. That's what caused
261
+ # tabby-profile-sync to emit a profile for a worktree directory.
262
+ resolve_canonical_repo_path() {
263
+ local input_path="$1"
264
+ local common_dir
265
+ common_dir=$(git -C "$input_path" rev-parse --git-common-dir 2>/dev/null) || {
266
+ printf '%s\n' "$input_path"
267
+ return 0
268
+ }
269
+ local own_git_dir
270
+ own_git_dir=$(git -C "$input_path" rev-parse --git-dir 2>/dev/null) || {
271
+ printf '%s\n' "$input_path"
272
+ return 0
273
+ }
274
+
275
+ # Resolve both to absolute paths for a reliable comparison.
276
+ # git -C <path> returns paths relative to <path> when they are relative.
277
+ local common_abs own_abs
278
+ if [[ "$common_dir" = /* ]]; then
279
+ common_abs=$(cd "$common_dir" 2>/dev/null && pwd -P)
280
+ else
281
+ common_abs=$(cd "$input_path/$common_dir" 2>/dev/null && pwd -P)
282
+ fi
283
+ if [[ "$own_git_dir" = /* ]]; then
284
+ own_abs=$(cd "$own_git_dir" 2>/dev/null && pwd -P)
285
+ else
286
+ own_abs=$(cd "$input_path/$own_git_dir" 2>/dev/null && pwd -P)
287
+ fi
288
+
289
+ if [[ -z "$common_abs" || -z "$own_abs" || "$common_abs" == "$own_abs" ]]; then
290
+ # Main worktree or degraded resolution — pass through.
291
+ printf '%s\n' "$input_path"
292
+ return 0
293
+ fi
294
+
295
+ # Linked worktree — ask git for the main worktree's working tree path.
296
+ local main_path
297
+ main_path=$(git -C "$input_path" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')
298
+ if [[ -n "$main_path" && "$main_path" != "$input_path" && -d "$main_path" ]]; then
299
+ printf '%s\n' "$main_path"
300
+ return 0
301
+ fi
302
+
303
+ printf '%s\n' "$input_path"
304
+ return 0
305
+ }
306
+
307
+ # Register a repo in repos.json
308
+ # Usage: register_repo <path> <version> <features>
309
+ register_repo() {
310
+ local repo_path="$1"
311
+ local version="$2"
312
+ local features="$3"
313
+
314
+ init_repos_file
315
+
316
+ # Normalize path (resolve symlinks, remove trailing slash)
317
+ if ! repo_path=$(cd "$repo_path" 2>/dev/null && pwd -P); then
318
+ print_warning "Cannot access path: $repo_path"
319
+ return 1
320
+ fi
321
+
322
+ # Resolve linked worktrees to their canonical main-worktree path.
323
+ # Every registration path (cmd_init, auto-discovery, scan) runs through
324
+ # register_repo, so the guard here catches all of them — not just the
325
+ # cmd_init path that previously checked only when WORKTREE_PATH was set.
326
+ local canonical_path
327
+ canonical_path=$(resolve_canonical_repo_path "$repo_path")
328
+ if [[ -n "$canonical_path" && "$canonical_path" != "$repo_path" ]]; then
329
+ print_info "Resolved worktree to canonical repo: $repo_path → $canonical_path"
330
+ if ! repo_path=$(cd "$canonical_path" 2>/dev/null && pwd -P); then
331
+ print_warning "Cannot access canonical path: $canonical_path"
332
+ return 1
333
+ fi
334
+ fi
335
+
336
+ if ! command -v jq &>/dev/null; then
337
+ print_warning "jq not installed - repo tracking disabled"
338
+ return 0
339
+ fi
340
+
341
+ # Auto-detect GitHub slug from git remote
342
+ local slug=""
343
+ local is_local_only="false"
344
+ if ! slug=$(get_repo_slug "$repo_path" 2>/dev/null); then
345
+ slug=""
346
+ # No remote origin — mark as local_only
347
+ if ! git -C "$repo_path" remote get-url origin &>/dev/null; then
348
+ is_local_only="true"
349
+ fi
350
+ fi
351
+
352
+ # Auto-detect maintainer from gh API (current authenticated user)
353
+ # Only runs once per registration — preserved on subsequent updates
354
+ local maintainer=""
355
+ if command -v gh &>/dev/null; then
356
+ maintainer=$(gh api user --jq '.login' 2>/dev/null) || maintainer=""
357
+ fi
358
+
359
+ local DEFAULT_PULSE="false"
360
+ local DEFAULT_PRIORITY=""
361
+ eval "$(_compute_repo_registration_defaults "$repo_path" "$slug" "$is_local_only" "$maintainer")"
362
+
363
+ # Infer default init_scope; pass is_local_only (already computed) to skip redundant I/O
364
+ local default_init_scope
365
+ default_init_scope=$(_infer_init_scope "$repo_path" "$is_local_only")
366
+
367
+ # Check if repo already registered
368
+ if jq -e --arg path "$repo_path" '.initialized_repos[] | select(.path == $path)' "$REPOS_FILE" &>/dev/null; then
369
+ # Update existing entry, preserving pulse/priority/local_only/maintainer/init_scope if already set
370
+ local temp_file="${REPOS_FILE}.tmp"
371
+ jq --arg path "$repo_path" --arg version "$version" --arg features "$features" \
372
+ --arg slug "$slug" --argjson local_only "$is_local_only" --arg maintainer "$maintainer" \
373
+ --argjson pulse_default "$DEFAULT_PULSE" --arg priority_default "$DEFAULT_PRIORITY" \
374
+ --arg init_scope_default "$default_init_scope" \
375
+ '(.initialized_repos[] | select(.path == $path)) |= (
376
+ . + {path: $path, version: $version, features: ($features | split(",")), updated: (now | strftime("%Y-%m-%dT%H:%M:%SZ"))}
377
+ | if $slug != "" then .slug = $slug else . end
378
+ | if $local_only then .local_only = true else . end
379
+ | if .pulse == null then .pulse = (if $local_only then false else $pulse_default end) else . end
380
+ | if (.priority == null or .priority == "") and $priority_default != "" then .priority = $priority_default else . end
381
+ | if (.maintainer == null or .maintainer == "") and $maintainer != "" then .maintainer = $maintainer else . end
382
+ | if (.init_scope == null or .init_scope == "") then .init_scope = $init_scope_default else . end
383
+ )' \
384
+ "$REPOS_FILE" >"$temp_file" && mv "$temp_file" "$REPOS_FILE"
385
+ else
386
+ # Add new entry with slug, defaults, maintainer, and init_scope
387
+ local temp_file="${REPOS_FILE}.tmp"
388
+ jq --arg path "$repo_path" --arg version "$version" --arg features "$features" \
389
+ --arg slug "$slug" --arg maintainer "$maintainer" \
390
+ --argjson local_only "$is_local_only" --argjson pulse_default "$DEFAULT_PULSE" \
391
+ --arg priority_default "$DEFAULT_PRIORITY" --arg init_scope "$default_init_scope" \
392
+ '.initialized_repos += [(
393
+ {
394
+ path: $path,
395
+ maintainer: $maintainer,
396
+ version: $version,
397
+ features: ($features | split(",")),
398
+ pulse: $pulse_default,
399
+ init_scope: $init_scope,
400
+ initialized: (now | strftime("%Y-%m-%dT%H:%M:%SZ"))
401
+ }
402
+ | if $slug != "" then . + {slug: $slug} else . end
403
+ | if $local_only then . + {local_only: true, pulse: false} else . end
404
+ | if $priority_default != "" then . + {priority: $priority_default} else . end
405
+ )]' \
406
+ "$REPOS_FILE" >"$temp_file" && mv "$temp_file" "$REPOS_FILE"
407
+ fi
408
+ return 0
409
+ }
410
+
411
+ # Get list of registered repos
412
+ get_registered_repos() {
413
+ init_repos_file
414
+
415
+ if ! command -v jq &>/dev/null; then
416
+ echo "[]"
417
+ return 0
418
+ fi
419
+
420
+ jq -r '.initialized_repos[] | .path' "$REPOS_FILE" 2>/dev/null || echo ""
421
+ return 0
422
+ }
423
+
424
+ # Get the maintainer GitHub username for a repo
425
+ # Fallback chain: maintainer field > slug owner > empty string
426
+ # Usage: get_repo_maintainer <slug>
427
+ get_repo_maintainer() {
428
+ local slug="$1"
429
+
430
+ if ! command -v jq &>/dev/null; then
431
+ echo ""
432
+ return 0
433
+ fi
434
+
435
+ local maintainer
436
+ maintainer=$(jq -r --arg slug "$slug" \
437
+ '.initialized_repos[] | select(.slug == $slug) | .maintainer // empty' \
438
+ "$REPOS_FILE" 2>/dev/null) || maintainer=""
439
+
440
+ if [[ -n "$maintainer" ]]; then
441
+ echo "$maintainer"
442
+ return 0
443
+ fi
444
+
445
+ # Fallback: extract owner from slug (owner/repo -> owner)
446
+ if [[ -n "$slug" && "$slug" == *"/"* ]]; then
447
+ echo "${slug%%/*}"
448
+ return 0
449
+ fi
450
+
451
+ echo ""
452
+ return 0
453
+ }
454
+
455
+ # Check if a repo needs upgrade (version behind current)
456
+ check_repo_needs_upgrade() {
457
+ local repo_path="$1"
458
+ local current_version
459
+ current_version=$(get_version)
460
+
461
+ if ! command -v jq &>/dev/null; then
462
+ return 1
463
+ fi
464
+
465
+ local repo_version
466
+ repo_version=$(jq -r --arg path "$repo_path" '.initialized_repos[] | select(.path == $path) | .version' "$REPOS_FILE" 2>/dev/null)
467
+
468
+ if [[ -z "$repo_version" || "$repo_version" == "null" ]]; then
469
+ return 1
470
+ fi
471
+
472
+ # Compare versions (simple string comparison works for semver)
473
+ if [[ "$repo_version" != "$current_version" ]]; then
474
+ return 0 # needs upgrade
475
+ fi
476
+ return 1 # up to date
477
+ }
478
+
479
+ # Check if a planning file needs upgrading (version mismatch or missing TOON markers)
480
+ # Usage: check_planning_file_version <file> <template>
481
+ # Returns 0 if upgrade needed, 1 if up to date
482
+ check_planning_file_version() {
483
+ local file="$1" template="$2"
484
+ if [[ -f "$file" ]]; then
485
+ if ! grep -q "TOON:meta" "$file" 2>/dev/null; then
486
+ return 0
487
+ fi
488
+ local current_ver template_ver
489
+ current_ver=$(grep -A1 "TOON:meta" "$file" 2>/dev/null | tail -1 | cut -d',' -f1)
490
+ template_ver=$(grep -A1 "TOON:meta" "$template" 2>/dev/null | tail -1 | cut -d',' -f1)
491
+ if [[ -n "$template_ver" ]] && [[ "$current_ver" != "$template_ver" ]]; then
492
+ return 0
493
+ fi
494
+ return 1
495
+ else
496
+ # No file = no upgrade needed (init would create it)
497
+ return 1
498
+ fi
499
+ }
500
+
501
+ # Check if a repo's planning templates need upgrading
502
+ # Returns 0 if any planning file needs upgrade
503
+ check_planning_needs_upgrade() {
504
+ local repo_path="$1"
505
+ local todo_file="$repo_path/TODO.md"
506
+ local plans_file="$repo_path/todo/PLANS.md"
507
+ local todo_template="$AGENTS_DIR/templates/todo-template.md"
508
+ local plans_template="$AGENTS_DIR/templates/plans-template.md"
509
+
510
+ [[ ! -f "$todo_template" ]] && return 1
511
+
512
+ if check_planning_file_version "$todo_file" "$todo_template"; then
513
+ return 0
514
+ fi
515
+ if [[ -f "$plans_template" ]] && check_planning_file_version "$plans_file" "$plans_template"; then
516
+ return 0
517
+ fi
518
+ return 1
519
+ }
520
+
521
+ # Detect if current directory has aidevops but isn't registered
522
+ detect_unregistered_repo() {
523
+ local project_root
524
+
525
+ # Check if in a git repo
526
+ if ! git rev-parse --is-inside-work-tree &>/dev/null; then
527
+ return 1
528
+ fi
529
+
530
+ project_root=$(git rev-parse --show-toplevel 2>/dev/null)
531
+
532
+ # Check for .aidevops.json
533
+ if [[ ! -f "$project_root/.aidevops.json" ]]; then
534
+ return 1
535
+ fi
536
+
537
+ init_repos_file
538
+
539
+ if ! command -v jq &>/dev/null; then
540
+ return 1
541
+ fi
542
+
543
+ # Check if already registered
544
+ if jq -e --arg path "$project_root" '.initialized_repos[] | select(.path == $path)' "$REPOS_FILE" &>/dev/null; then
545
+ return 1 # already registered
546
+ fi
547
+
548
+ # Not registered - return the path
549
+ echo "$project_root"
550
+ return 0
551
+ }
552
+
553
+ # Check if on protected branch and offer worktree creation
554
+ # Returns 0 if safe to proceed, 1 if user cancelled
555
+ # Sets WORKTREE_PATH if worktree was created
556
+ check_protected_branch() {
557
+ local branch_type="${1:-chore}"
558
+ local branch_suffix="${2:-aidevops-setup}"
559
+
560
+ # Not in a git repo - skip check
561
+ if ! git rev-parse --is-inside-work-tree &>/dev/null; then
562
+ return 0
563
+ fi
564
+
565
+ local current_branch
566
+ current_branch=$(git branch --show-current 2>/dev/null || echo "")
567
+
568
+ # Not on a protected branch - safe to proceed
569
+ if [[ ! "$current_branch" =~ ^(main|master)$ ]]; then
570
+ return 0
571
+ fi
572
+
573
+ local project_root
574
+ project_root=$(git rev-parse --show-toplevel)
575
+ local repo_name
576
+ repo_name=$(basename "$project_root")
577
+ local suggested_branch="$branch_type/$branch_suffix"
578
+
579
+ local choice
580
+ # In non-interactive (non-TTY) contexts, auto-select option 1 (create worktree)
581
+ # without prompting. This prevents read from blocking or getting EOF in CI/AI
582
+ # assistant environments, which could cause silent script termination with set -e.
583
+ if [[ -t 0 ]]; then
584
+ echo ""
585
+ print_warning "On protected branch '$current_branch'"
586
+ echo ""
587
+ echo "Options:"
588
+ echo " 1. Create worktree: $suggested_branch (recommended)"
589
+ echo " 2. Continue on $current_branch (commits directly to main)"
590
+ echo " 3. Cancel"
591
+ echo ""
592
+ read -r -p "Choice [1]: " choice
593
+ choice="${choice:-1}"
594
+ else
595
+ # Non-interactive: auto-create worktree (safest default)
596
+ choice="1"
597
+ print_info "Non-interactive mode: auto-selecting worktree creation for '$suggested_branch'"
598
+ fi
599
+
600
+ case "$choice" in
601
+ 1)
602
+ # Create worktree
603
+ local worktree_dir
604
+ worktree_dir="$(dirname "$project_root")/${repo_name}-${branch_type}-${branch_suffix}"
605
+
606
+ print_info "Creating worktree at $worktree_dir..."
607
+
608
+ local worktree_created=false
609
+ if [[ -f "$AGENTS_DIR/scripts/worktree-helper.sh" ]]; then
610
+ if bash "$AGENTS_DIR/scripts/worktree-helper.sh" add "$suggested_branch"; then
611
+ worktree_created=true
612
+ else
613
+ print_error "Failed to create worktree via worktree-helper.sh"
614
+ return 1
615
+ fi
616
+ else
617
+ # Fallback without helper script
618
+ if git worktree add -b "$suggested_branch" "$worktree_dir"; then
619
+ worktree_created=true
620
+ else
621
+ print_error "Failed to create worktree"
622
+ return 1
623
+ fi
624
+ fi
625
+
626
+ if [[ "$worktree_created" == "true" ]]; then
627
+ export WORKTREE_PATH="$worktree_dir"
628
+ echo ""
629
+ print_success "Worktree created at: $worktree_dir"
630
+ print_info "Switching to: $worktree_dir"
631
+ echo ""
632
+ # Change to worktree directory for the remainder of this process
633
+ cd "$worktree_dir" || return 1
634
+ return 0
635
+ fi
636
+ ;;
637
+ 2)
638
+ print_warning "Continuing on $current_branch - changes will commit directly"
639
+ return 0
640
+ ;;
641
+ 3 | *)
642
+ print_info "Cancelled"
643
+ return 1
644
+ ;;
645
+ esac
646
+ }
647
+