@humanu/orchestra 0.5.2 → 0.5.4

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.
package/install.js CHANGED
@@ -3,86 +3,166 @@
3
3
  const { execSync } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const os = require('os');
7
6
 
8
- const BINARY_NAME = 'gw-tui';
7
+ const BINARY_NAME = 'orchestra';
8
+ const SHELL_SCRIPTS = ['gwr.sh', 'gw.sh', 'gw-bridge.sh', 'commands.sh'];
9
9
  const SUPPORTED_PLATFORMS = {
10
- 'darwin-x64': 'macos-x64',
10
+ 'darwin-x64': 'macos-intel',
11
11
  'darwin-arm64': 'macos-arm64',
12
12
  'linux-x64': 'linux-x64',
13
13
  'linux-arm64': 'linux-arm64'
14
14
  };
15
15
 
16
- function getPlatform() {
17
- const platform = `${process.platform}-${process.arch}`;
18
- if (!SUPPORTED_PLATFORMS[platform]) {
19
- console.error(`Unsupported platform: ${platform}`);
20
- console.error('Supported platforms:', Object.keys(SUPPORTED_PLATFORMS).join(', '));
16
+ const packageRoot = __dirname;
17
+ const resourcesDir = path.join(packageRoot, 'resources');
18
+ const distDir = path.join(packageRoot, 'dist');
19
+ const scriptsDir = path.join(resourcesDir, 'scripts');
20
+ const apiDir = path.join(resourcesDir, 'api');
21
+ const prebuiltDir = path.join(resourcesDir, 'prebuilt');
22
+ const projectRoot = path.resolve(packageRoot, '..', '..');
23
+ const hasLocalSource = fs.existsSync(path.join(projectRoot, 'gw-tui'));
24
+
25
+ function ensureDir(dir) {
26
+ if (!fs.existsSync(dir)) {
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ }
29
+ }
30
+
31
+ function cleanDir(dir) {
32
+ if (fs.existsSync(dir)) {
33
+ fs.rmSync(dir, { recursive: true, force: true });
34
+ }
35
+ ensureDir(dir);
36
+ }
37
+
38
+ function getPlatformKey() {
39
+ const key = `${process.platform}-${process.arch}`;
40
+ const mapped = SUPPORTED_PLATFORMS[key];
41
+ if (!mapped) {
42
+ console.error(`Unsupported platform: ${key}`);
43
+ console.error(`Supported platforms: ${Object.keys(SUPPORTED_PLATFORMS).join(', ')}`);
21
44
  process.exit(1);
22
45
  }
23
- return SUPPORTED_PLATFORMS[platform];
46
+ return mapped;
47
+ }
48
+
49
+ function copyShellScripts() {
50
+ let sourceBase = scriptsDir;
51
+ if (!fs.existsSync(sourceBase)) {
52
+ if (!hasLocalSource) {
53
+ console.error('Shell scripts are missing from the package.');
54
+ process.exit(1);
55
+ }
56
+ sourceBase = projectRoot;
57
+ }
58
+
59
+ for (const script of SHELL_SCRIPTS) {
60
+ const sourcePath = path.join(sourceBase, script);
61
+ if (!fs.existsSync(sourcePath)) {
62
+ console.error(`Required script missing: ${sourcePath}`);
63
+ process.exit(1);
64
+ }
65
+ const destinationPath = path.join(distDir, script);
66
+ fs.copyFileSync(sourcePath, destinationPath);
67
+ fs.chmodSync(destinationPath, 0o755);
68
+ }
69
+ }
70
+
71
+ function copyApiScripts() {
72
+ let sourceBase = apiDir;
73
+ if (!fs.existsSync(sourceBase)) {
74
+ if (!hasLocalSource) {
75
+ console.error('API scripts are missing from the package.');
76
+ process.exit(1);
77
+ }
78
+ sourceBase = path.join(projectRoot, 'api');
79
+ }
80
+
81
+ ensureDir(path.join(distDir, 'api'));
82
+ const apiFiles = fs.existsSync(sourceBase) ? fs.readdirSync(sourceBase) : [];
83
+ for (const file of apiFiles) {
84
+ if (!file.endsWith('.sh')) {
85
+ continue;
86
+ }
87
+ const sourcePath = path.join(sourceBase, file);
88
+ const destinationPath = path.join(distDir, 'api', file);
89
+ fs.copyFileSync(sourcePath, destinationPath);
90
+ fs.chmodSync(destinationPath, 0o755);
91
+ }
92
+ }
93
+
94
+ function linkCompatibilityBinary(binaryPath) {
95
+ const gwTuiPath = path.join(distDir, 'gw-tui');
96
+ try {
97
+ fs.rmSync(gwTuiPath, { force: true });
98
+ } catch (err) {
99
+ if (err.code !== 'ENOENT') {
100
+ throw err;
101
+ }
102
+ }
103
+
104
+ try {
105
+ fs.symlinkSync(binaryPath, gwTuiPath);
106
+ } catch (err) {
107
+ // Fallback for filesystems that disallow symlinks
108
+ fs.copyFileSync(binaryPath, gwTuiPath);
109
+ fs.chmodSync(gwTuiPath, 0o755);
110
+ }
24
111
  }
25
112
 
26
- function downloadPrebuiltBinary() {
27
- const platform = getPlatform();
28
- const binaryPath = path.join(__dirname, 'dist', BINARY_NAME);
29
- const prebuiltPath = path.join(__dirname, 'dist', 'prebuilt', platform, BINARY_NAME);
30
-
31
- // Check if prebuilt binary exists
32
- if (fs.existsSync(prebuiltPath)) {
33
- console.log(`Using prebuilt binary for ${platform}`);
34
- fs.copyFileSync(prebuiltPath, binaryPath);
35
- fs.chmodSync(binaryPath, 0o755);
36
- return true;
113
+ function installPrebuiltBinary() {
114
+ const platform = getPlatformKey();
115
+ const source = path.join(prebuiltDir, platform, BINARY_NAME);
116
+ if (!fs.existsSync(source)) {
117
+ return false;
37
118
  }
38
-
39
- return false;
119
+
120
+ const destination = path.join(distDir, BINARY_NAME);
121
+ fs.copyFileSync(source, destination);
122
+ fs.chmodSync(destination, 0o755);
123
+ linkCompatibilityBinary(destination);
124
+ return true;
40
125
  }
41
126
 
42
127
  function buildFromSource() {
43
- console.log('Building from source...');
44
-
45
- // Check for Rust
128
+ if (!hasLocalSource) {
129
+ console.error('No prebuilt binary available and source tree not found.');
130
+ console.error('Clone the repository and run `cargo build --release` to build locally.');
131
+ process.exit(1);
132
+ }
133
+
46
134
  try {
47
135
  execSync('cargo --version', { stdio: 'ignore' });
48
136
  } catch (error) {
49
- console.error('Rust is not installed. Please install Rust from https://rustup.rs/');
137
+ console.error('Rust toolchain not found. Install Rust from https://rustup.rs/.');
50
138
  process.exit(1);
51
139
  }
52
-
53
- // Build the Rust binary
54
- try {
55
- console.log('Building gw-tui...');
56
- // Go up two directories to find gw-tui (npm package is in install/npm/)
57
- const projectRoot = path.join(__dirname, '..', '..');
58
- execSync('cd gw-tui && cargo build --release', {
59
- stdio: 'inherit',
60
- cwd: projectRoot
61
- });
62
-
63
- // Copy the built binary to dist
64
- const sourcePath = path.join(projectRoot, 'gw-tui', 'target', 'release', BINARY_NAME);
65
- const destPath = path.join(__dirname, 'dist', BINARY_NAME);
66
-
67
- fs.copyFileSync(sourcePath, destPath);
68
- fs.chmodSync(destPath, 0o755);
69
-
70
- console.log('Successfully built from source');
71
- } catch (error) {
72
- console.error('Failed to build from source:', error.message);
140
+
141
+ console.log('Building from source...');
142
+ execSync('cargo build --release', { stdio: 'inherit', cwd: path.join(projectRoot, 'gw-tui') });
143
+
144
+ const builtBinary = path.join(projectRoot, 'gw-tui', 'target', 'release', 'gw-tui');
145
+ if (!fs.existsSync(builtBinary)) {
146
+ console.error('Build finished but gw-tui binary was not produced.');
73
147
  process.exit(1);
74
148
  }
149
+
150
+ const destination = path.join(distDir, BINARY_NAME);
151
+ fs.copyFileSync(builtBinary, destination);
152
+ fs.chmodSync(destination, 0o755);
153
+ linkCompatibilityBinary(destination);
75
154
  }
76
155
 
77
- function setupShellWrappers() {
78
- console.log('\n📝 Shell wrapper functions needed for directory switching:');
79
- console.log('Add these to your ~/.bashrc or ~/.zshrc:\n');
80
-
81
- const installPath = __dirname;
82
-
156
+ function printShellWrapperInstructions(binaryPath) {
157
+ const distPathEscaped = distDir.split('\\').join('\\\\');
158
+ const binaryPathEscaped = binaryPath.split('\\').join('\\\\');
159
+
160
+ console.log('\n📝 Shell wrapper functions needed for directory switching:\n');
83
161
  console.log(`# GW Orchestrator shell wrappers
84
162
  gwr() {
85
- local out="$(${path.join(installPath, 'dist', 'gwr.sh')} "$@")"
163
+ local dist_path="${distPathEscaped}"
164
+ local bin_path="${binaryPathEscaped}"
165
+ local out="$(GW_TUI_BIN="$bin_path" bash "$dist_path/gwr.sh" "$@")"
86
166
  local status=$?
87
167
  local cd_line="$(echo "$out" | grep -m1 '^cd')"
88
168
  [[ -n $cd_line ]] && eval "$cd_line"
@@ -91,65 +171,34 @@ gwr() {
91
171
  }
92
172
 
93
173
  gw() {
94
- local out="$(${path.join(installPath, 'dist', 'gw.sh')} "$@")"
174
+ local dist_path="${distPathEscaped}"
175
+ local out="$(bash "$dist_path/gw.sh" "$@")"
95
176
  local status=$?
96
177
  local cd_line="$(echo "$out" | grep -m1 '^cd')"
97
178
  [[ -n $cd_line ]] && eval "$cd_line"
98
179
  echo "$out" | grep -v '^cd'
99
180
  return $status
100
181
  }`);
101
-
102
- console.log('\nThen run: source ~/.bashrc (or source ~/.zshrc)');
182
+ console.log('\nAdd these to your ~/.bashrc or ~/.zshrc, then run: source ~/.bashrc (or source ~/.zshrc)\n');
103
183
  }
104
184
 
105
185
  function main() {
106
- console.log('Installing gw-orchestrator...\n');
107
-
108
- // Create dist directory if it doesn't exist
109
- const distDir = path.join(__dirname, 'dist');
110
- if (!fs.existsSync(distDir)) {
111
- fs.mkdirSync(distDir, { recursive: true });
112
- }
113
-
114
- // Copy shell scripts to dist (from project root, since npm package is in install/npm/)
115
- const projectRoot = path.join(__dirname, '..', '..');
116
- const shellScripts = ['gwr.sh', 'gw.sh', 'gw-bridge.sh', 'commands.sh'];
117
- const apiDir = path.join(projectRoot, 'api');
118
-
119
- shellScripts.forEach(script => {
120
- const sourcePath = path.join(projectRoot, script);
121
- const destPath = path.join(distDir, script);
122
- if (fs.existsSync(sourcePath)) {
123
- fs.copyFileSync(sourcePath, destPath);
124
- fs.chmodSync(destPath, 0o755);
125
- }
126
- });
127
-
128
- // Copy api directory
129
- const distApiDir = path.join(distDir, 'api');
130
- if (!fs.existsSync(distApiDir)) {
131
- fs.mkdirSync(distApiDir, { recursive: true });
132
- }
133
-
134
- if (fs.existsSync(apiDir)) {
135
- fs.readdirSync(apiDir).forEach(file => {
136
- const sourcePath = path.join(apiDir, file);
137
- const destPath = path.join(distApiDir, file);
138
- fs.copyFileSync(sourcePath, destPath);
139
- fs.chmodSync(destPath, 0o755);
140
- });
141
- }
142
-
143
- // Try to use prebuilt binary, otherwise build from source
144
- if (!downloadPrebuiltBinary()) {
186
+ console.log('Installing Orchestra...\n');
187
+
188
+ cleanDir(distDir);
189
+
190
+ copyShellScripts();
191
+ copyApiScripts();
192
+
193
+ const binaryInstalled = installPrebuiltBinary();
194
+ if (!binaryInstalled) {
145
195
  buildFromSource();
146
196
  }
147
-
197
+
198
+ const binaryPath = path.join(distDir, BINARY_NAME);
199
+
148
200
  console.log('\n✅ Installation complete!');
149
-
150
- // Setup shell wrappers
151
- setupShellWrappers();
201
+ printShellWrapperInstructions(binaryPath);
152
202
  }
153
203
 
154
- // Run installation
155
- main();
204
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanu/orchestra",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "AI-powered Git worktree and tmux session manager with modern TUI",
5
5
  "keywords": [
6
6
  "git",
@@ -26,12 +26,13 @@
26
26
  "author": "Human Unsupervised",
27
27
  "main": "install.js",
28
28
  "bin": {
29
- "orchestra": "./install.js",
30
- "gwr": "./install.js",
31
- "gw": "./install.js"
29
+ "orchestra": "install.js",
30
+ "gwr": "install.js",
31
+ "gw": "install.js"
32
32
  },
33
33
  "files": [
34
34
  "bin/",
35
+ "resources/",
35
36
  "install.js",
36
37
  "README.md"
37
38
  ],
@@ -0,0 +1,359 @@
1
+ #!/bin/bash
2
+
3
+ ###############################################################################
4
+ # git.sh – Git Worktree API Module
5
+ # ---------------------------------------------------------------------------
6
+ # This module provides a centralized API for all Git operations used by
7
+ # gw.sh, commands.sh, and gw-bridge.sh. It abstracts Git worktree management
8
+ # and other Git operations into a clean, consistent interface.
9
+ ###############################################################################
10
+
11
+ # --------------------------- Repository Information -------------------------
12
+
13
+ # Get the Git repository root directory
14
+ git_repo_root() {
15
+ git rev-parse --show-toplevel 2>/dev/null || true
16
+ }
17
+
18
+ # Check if current directory is in a Git repository
19
+ git_is_repo() {
20
+ git rev-parse --git-dir >/dev/null 2>&1
21
+ }
22
+
23
+ # Get current absolute path (resolved)
24
+ git_current_path() {
25
+ pwd -P
26
+ }
27
+
28
+ # Convert branch name to slug (replace / with -)
29
+ git_branch_to_slug() {
30
+ echo "$1" | tr '/' '-'
31
+ }
32
+
33
+ # --------------------------- Branch Operations ------------------------------
34
+
35
+ # Check if a branch exists
36
+ git_branch_exists() {
37
+ local branch_name="$1"
38
+ git rev-parse --verify "$branch_name" >/dev/null 2>&1
39
+ }
40
+
41
+ # Get current branch name (or empty if detached HEAD)
42
+ git_current_branch() {
43
+ git symbolic-ref --short HEAD 2>/dev/null || true
44
+ }
45
+
46
+ # Get current commit SHA (short format)
47
+ git_current_commit_short() {
48
+ git rev-parse --short HEAD 2>/dev/null || true
49
+ }
50
+
51
+ # Delete a local branch (force delete)
52
+ git_delete_branch() {
53
+ local branch_name="$1"
54
+ git branch -D "$branch_name" 2>/dev/null || true
55
+ }
56
+
57
+ # --------------------------- Worktree Operations ----------------------------
58
+
59
+ # Ensure ignore rules for worktrees directory exist in both .gitignore and .git/info/exclude
60
+ # Non-destructive and idempotent; creates files if needed
61
+ git_ensure_ignore_worktrees() {
62
+ local root; root="$(git_repo_root)"
63
+ [[ -z "$root" ]] && return 1
64
+
65
+ # .git/info/exclude (local-only)
66
+ local exclude_file="$root/.git/info/exclude"
67
+ mkdir -p "$root/.git/info" 2>/dev/null || true
68
+ if [[ -f "$exclude_file" ]]; then
69
+ if ! grep -q -E '^worktrees/(\s*)?$' "$exclude_file" 2>/dev/null; then
70
+ echo 'worktrees/' >> "$exclude_file"
71
+ fi
72
+ else
73
+ echo 'worktrees/' >> "$exclude_file"
74
+ fi
75
+
76
+ # Top-level .gitignore (shared)
77
+ local gi_file="$root/.gitignore"
78
+ if [[ -f "$gi_file" ]]; then
79
+ if ! grep -q -E '^worktrees/(\s*)?$' "$gi_file" 2>/dev/null; then
80
+ echo 'worktrees/' >> "$gi_file"
81
+ fi
82
+ else
83
+ echo 'worktrees/' > "$gi_file"
84
+ fi
85
+ }
86
+
87
+ # List all worktrees: returns lines with "path\tbranch\tsha_short"
88
+ git_list_worktrees() {
89
+ local root; root="$(git_repo_root)"
90
+ [[ -z "$root" ]] && return 1
91
+
92
+ (cd "$root" && git worktree list) | awk '
93
+ {
94
+ p=""; for (i=1; i<=NF-2; i++) p=(p==""?$i:p" " $i);
95
+ sha_full=$(NF-1); sha=substr(sha_full,1,7);
96
+ b=$NF; gsub(/^\[|\]$/, "", b);
97
+ print p "\t" b "\t" sha;
98
+ }'
99
+ }
100
+
101
+ # Get branch name for a given worktree path
102
+ git_worktree_path_to_branch() {
103
+ local path="$1"
104
+ local root; root="$(git_repo_root)"
105
+ [[ -z "$root" ]] && return 1
106
+
107
+ (cd "$root" && git worktree list) | awk -v target="$path" '
108
+ {
109
+ p=""; for (i=1; i<=NF-2; i++) p=(p==""?$i:p" " $i);
110
+ b=$NF; gsub(/^\[|\]$/, "", b);
111
+ if (p==target) {print b; exit}
112
+ }'
113
+ }
114
+
115
+ # Get worktree path for a given branch name
116
+ git_branch_to_worktree_path() {
117
+ local branch_name="$1"
118
+ local root; root="$(git_repo_root)"
119
+ [[ -z "$root" ]] && return 1
120
+
121
+ (cd "$root" && git worktree list) | awk -v target="[$branch_name]" '
122
+ {
123
+ p=""; for (i=1; i<=NF-2; i++) p=(p==""?$i:p" " $i);
124
+ if ($NF==target) {print p; exit}
125
+ }'
126
+ }
127
+
128
+ # Create a new worktree with a new branch
129
+ git_create_worktree_with_branch() {
130
+ local branch_name="$1"
131
+ local worktree_path="$2"
132
+ local root; root="$(git_repo_root)"
133
+ [[ -z "$root" ]] && return 1
134
+
135
+ local relative_path="${worktree_path#$root/}"
136
+ (cd "$root" && git worktree add "$relative_path" -b "$branch_name" >&2)
137
+ }
138
+
139
+ # Create a new worktree from existing branch
140
+ git_create_worktree_from_branch() {
141
+ local branch_name="$1"
142
+ local worktree_path="$2"
143
+ local root; root="$(git_repo_root)"
144
+ [[ -z "$root" ]] && return 1
145
+
146
+ local relative_path="${worktree_path#$root/}"
147
+ (cd "$root" && git worktree add "$relative_path" "$branch_name" >&2)
148
+ }
149
+
150
+ git_create_worktree_for_existing_branch() {
151
+ local branch_name="$1"
152
+ local root; root="$(git_repo_root)"
153
+ [[ -z "$root" ]] && return 1
154
+
155
+ git_branch_exists "$branch_name" || { echo "Branch does not exist: $branch_name" >&2; return 1; }
156
+
157
+ git_ensure_ignore_worktrees >/dev/null 2>&1 || true
158
+
159
+ local worktree_path; worktree_path="$(git_build_worktree_path "$branch_name")"
160
+ mkdir -p "$(dirname "$worktree_path")"
161
+
162
+ git_create_worktree_from_branch "$branch_name" "$worktree_path" || return 1
163
+
164
+ echo "$worktree_path"
165
+ }
166
+
167
+ git_create_worktree_from_remote_branch() {
168
+ local branch_name="$1"
169
+ local remote_name="${2:-origin}"
170
+ local root; root="$(git_repo_root)"
171
+ [[ -z "$root" ]] && return 1
172
+
173
+ git_ensure_ignore_worktrees >/dev/null 2>&1 || true
174
+
175
+ local worktree_path; worktree_path="$(git_build_worktree_path "$branch_name")"
176
+ mkdir -p "$(dirname "$worktree_path")"
177
+
178
+ local relative_path="${worktree_path#$root/}"
179
+ (cd "$root" && git worktree add "$relative_path" -b "$branch_name" "$remote_name/$branch_name" >&2) || return 1
180
+
181
+ echo "$worktree_path"
182
+ }
183
+
184
+ # Remove a worktree (with force fallback)
185
+ git_remove_worktree() {
186
+ local worktree_path="$1"
187
+ local root; root="$(git_repo_root)"
188
+ [[ -z "$root" ]] && return 1
189
+
190
+ local relative_path="${worktree_path#$root/}"
191
+ (cd "$root" && {
192
+ git worktree remove "$relative_path" 2>/dev/null || \
193
+ git worktree remove --force "$relative_path" 2>/dev/null || true
194
+ })
195
+ }
196
+
197
+ # Get Git status for current directory
198
+ git_status() {
199
+ git status "$@"
200
+ }
201
+
202
+ # Get short Git status for current directory
203
+ git_status_short() {
204
+ git status --short
205
+ }
206
+
207
+ # --------------------------- Worktree Path Utilities ------------------------
208
+
209
+ # Build standard worktree path for a branch
210
+ git_build_worktree_path() {
211
+ local branch_name="$1"
212
+ local root; root="$(git_repo_root)"
213
+ [[ -z "$root" ]] && return 1
214
+
215
+ local slug; slug="$(git_branch_to_slug "$branch_name")"
216
+ echo "$root/worktrees/$slug"
217
+ }
218
+
219
+ # Check if a worktree exists for a branch
220
+ git_worktree_exists_for_branch() {
221
+ local branch_name="$1"
222
+ local path; path="$(git_branch_to_worktree_path "$branch_name")"
223
+ [[ -n "$path" ]]
224
+ }
225
+
226
+ # Get or create worktree path for a branch
227
+ git_ensure_worktree_for_branch() {
228
+ local branch_name="$1"
229
+ local root; root="$(git_repo_root)"
230
+ [[ -z "$root" ]] && return 1
231
+
232
+ # Check if worktree already exists
233
+ local existing_path; existing_path="$(git_branch_to_worktree_path "$branch_name")"
234
+ if [[ -n "$existing_path" ]]; then
235
+ echo "$existing_path"
236
+ return 0
237
+ fi
238
+
239
+ # Create worktree if branch exists
240
+ if git_branch_exists "$branch_name"; then
241
+ # Ensure ignores before creating worktree
242
+ git_ensure_ignore_worktrees >/dev/null 2>&1 || true
243
+ local worktree_path; worktree_path="$(git_build_worktree_path "$branch_name")"
244
+ mkdir -p "$(dirname "$worktree_path")"
245
+ git_create_worktree_from_branch "$branch_name" "$worktree_path"
246
+ echo "$worktree_path"
247
+ return 0
248
+ fi
249
+
250
+ return 1
251
+ }
252
+
253
+ # Create new branch and worktree
254
+ git_create_branch_and_worktree() {
255
+ local branch_name="$1"
256
+ local root; root="$(git_repo_root)"
257
+ [[ -z "$root" ]] && return 1
258
+
259
+ # Ensure ignores before creating worktree
260
+ git_ensure_ignore_worktrees >/dev/null 2>&1 || true
261
+ local worktree_path; worktree_path="$(git_build_worktree_path "$branch_name")"
262
+ mkdir -p "$(dirname "$worktree_path")"
263
+ git_create_worktree_with_branch "$branch_name" "$worktree_path"
264
+ echo "$worktree_path"
265
+ }
266
+
267
+ # --------------------------- Validation Functions ---------------------------
268
+
269
+ # Validate that we're in a Git repository
270
+ git_require_repo() {
271
+ git_is_repo || { echo "❌ Not a git repository or git command not found." >&2; return 1; }
272
+ }
273
+
274
+ # Get repository root with validation
275
+ git_require_repo_root() {
276
+ local root; root="$(git_repo_root)"
277
+ [[ -z "$root" ]] && { echo "❌ Not a git repository" >&2; return 1; }
278
+ echo "$root"
279
+ }
280
+
281
+ # --------------------------- Merge/Rebase Helpers ----------------------------
282
+
283
+ # Detect the primary branch name (default remote HEAD), fall back to common names
284
+ # Echoes the branch name on success; returns non-zero on failure
285
+ git_primary_branch() {
286
+ local head_ref branch
287
+ # Try origin/HEAD symbolic ref
288
+ head_ref=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || true)
289
+ if [[ -n "$head_ref" ]]; then
290
+ # refs/remotes/origin/main -> extract last component
291
+ branch="${head_ref##*/}"
292
+ [[ -n "$branch" ]] && { echo "$branch"; return 0; }
293
+ fi
294
+ # Try parsing `git remote show origin`
295
+ branch=$(git remote show origin 2>/dev/null | awk -F': ' '/HEAD branch/ {print $2; exit}')
296
+ if [[ -n "$branch" && "$branch" != "(unknown)" ]]; then
297
+ echo "$branch"; return 0
298
+ fi
299
+ # Fallback candidates
300
+ for cand in main master trunk; do
301
+ if git rev-parse --verify "$cand" >/dev/null 2>&1 || git rev-parse --verify "origin/$cand" >/dev/null 2>&1; then
302
+ echo "$cand"; return 0
303
+ fi
304
+ done
305
+ return 1
306
+ }
307
+
308
+ # Check if a worktree at path is clean (no changes)
309
+ # Usage: git_is_worktree_clean <path>
310
+ git_is_worktree_clean() {
311
+ local path="$1"
312
+ [[ -z "$path" ]] && return 1
313
+ local out
314
+ out=$(git -C "$path" status --porcelain 2>/dev/null || true)
315
+ [[ -z "$out" ]]
316
+ }
317
+
318
+ # Fetch from origin with prune for a given path
319
+ git_fetch_prune() {
320
+ local path="$1"
321
+ git -C "$path" fetch origin --prune 2>&1
322
+ }
323
+
324
+ # Fast-forward-only pull for the currently checked out branch at path
325
+ # Returns non-zero if not a fast-forward
326
+ # Usage: git_pull_ff_only <path>
327
+ git_pull_ff_only() {
328
+ local path="$1"
329
+ git -C "$path" pull --ff-only 2>&1
330
+ }
331
+
332
+ # Merge a ref into the currently checked out branch at target path
333
+ # Usage: git_merge_into <target_path> <ref>
334
+ git_merge_into() {
335
+ local target="$1"; local ref="$2"
336
+ git -C "$target" merge "$ref" 2>&1
337
+ }
338
+
339
+ # Rebase the current branch at target path onto upstream ref
340
+ # Usage: git_rebase_onto <target_path> <upstream_ref>
341
+ git_rebase_onto() {
342
+ local target="$1"; local upstream="$2"
343
+ git -C "$target" rebase "$upstream" 2>&1
344
+ }
345
+
346
+ # Squash-merge branch into the primary worktree path, then commit if changes staged
347
+ # Usage: git_squash_merge_into <primary_path> <branch>
348
+ git_squash_merge_into() {
349
+ local primary_path="$1"; local branch="$2"
350
+ git -C "$primary_path" merge --squash "$branch" 2>&1
351
+ }
352
+
353
+ # Ensure a primary worktree exists; echoes the path
354
+ # Usage: git_ensure_primary_worktree
355
+ git_ensure_primary_worktree() {
356
+ local primary
357
+ primary=$(git_primary_branch) || return 1
358
+ git_ensure_worktree_for_branch "$primary"
359
+ }