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
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export default antfu({
'CLAUDE.md',
'docs-dev/**/*.md',
'docs/**/*.md',
// Ignore planning and memory documents with illustrative code blocks
'.please/**/*.md',
],
}, {
rules: {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/git-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ export interface GitResult {
}

/**
* Execute a git command and return the result
* Execute a CLI command (git or gh) and return the result
*/
export async function runGitCommand(args: string[]): Promise<GitResult> {
export async function runCliCommand(args: string[]): Promise<GitResult> {
const proc = Bun.spawn(args, {
stdout: 'pipe',
stderr: 'pipe',
Expand Down
20 changes: 10 additions & 10 deletions src/lib/git-workflow-worktree.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { WorktreeInfo } from '../types'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { runGitCommand, warnWithFollowup } from './git-exec'
import { runCliCommand, warnWithFollowup } from './git-exec'

/**
* Ensure parent directory exists for a target path
Expand Down Expand Up @@ -34,7 +34,7 @@ export async function createWorktree(
): Promise<void> {
const expandedPath = await ensureParentDirectory(targetPath)

const result = await runGitCommand(['git', '-C', bareRepoPath, 'worktree', 'add', expandedPath, branch])
const result = await runCliCommand(['git', '-C', bareRepoPath, 'worktree', 'add', expandedPath, branch])

if (result.exitCode !== 0) {
throw new Error(`Failed to create worktree: ${result.stderr.trim()}`)
Expand All @@ -55,7 +55,7 @@ export async function createWorktreeFromRepo(

// Step 1: Fetch the branch with explicit refspec to update remote-tracking ref
// Using refspec ensures refs/remotes/origin/{branch} is updated
const fetchResult = await runGitCommand([
const fetchResult = await runCliCommand([
'git',
'--git-dir',
gitDir,
Expand All @@ -75,7 +75,7 @@ export async function createWorktreeFromRepo(

// Step 2: Check if remote-tracking ref exists before updating local branch
// Using rev-parse is language-agnostic (avoids relying on localized error messages)
const remoteRefCheck = await runGitCommand([
const remoteRefCheck = await runCliCommand([
'git',
'--git-dir',
gitDir,
Expand All @@ -89,7 +89,7 @@ export async function createWorktreeFromRepo(
// Step 3: Update local branch to match remote (only if remote ref exists)
// This ensures the worktree uses the latest code, not stale local branch
if (remoteRefExists) {
const branchResult = await runGitCommand([
const branchResult = await runCliCommand([
'git',
'--git-dir',
gitDir,
Expand All @@ -109,21 +109,21 @@ export async function createWorktreeFromRepo(
}

// Check if branch exists locally (for worktree add command selection)
const checkResult = await runGitCommand(['git', '--git-dir', gitDir, 'rev-parse', '--verify', `refs/heads/${branch}`])
const checkResult = await runCliCommand(['git', '--git-dir', gitDir, 'rev-parse', '--verify', `refs/heads/${branch}`])
const branchExists = checkResult.exitCode === 0

// Create worktree with the branch
const worktreeArgs = branchExists
? ['git', '--git-dir', gitDir, 'worktree', 'add', expandedPath, branch]
: ['git', '--git-dir', gitDir, 'worktree', 'add', '-b', branch, expandedPath, `origin/${branch}`]

const worktreeResult = await runGitCommand(worktreeArgs)
const worktreeResult = await runCliCommand(worktreeArgs)
if (worktreeResult.exitCode !== 0) {
throw new Error(`Failed to create worktree at '${expandedPath}' for branch '${branch}': ${worktreeResult.stderr.trim()}`)
}

// Set upstream tracking for the branch in the worktree
const trackingResult = await runGitCommand(['git', '-C', expandedPath, 'branch', '--set-upstream-to', `origin/${branch}`, branch])
const trackingResult = await runCliCommand(['git', '-C', expandedPath, 'branch', '--set-upstream-to', `origin/${branch}`, branch])
if (trackingResult.exitCode !== 0) {
warnWithFollowup(
`Could not set upstream tracking: ${trackingResult.stderr.trim() || 'unknown error'}`,
Expand All @@ -136,7 +136,7 @@ export async function createWorktreeFromRepo(
* List all worktrees for a repository
*/
export async function listWorktrees(bareRepoPath: string): Promise<WorktreeInfo[]> {
const result = await runGitCommand(['git', '-C', bareRepoPath, 'worktree', 'list', '--porcelain'])
const result = await runCliCommand(['git', '-C', bareRepoPath, 'worktree', 'list', '--porcelain'])

if (result.exitCode !== 0) {
// Log warning for debugging but return empty to allow caller to continue
Expand Down Expand Up @@ -196,7 +196,7 @@ function parseWorktreeOutput(output: string): WorktreeInfo[] {
* Remove worktree at path
*/
export async function removeWorktree(worktreePath: string): Promise<void> {
const result = await runGitCommand(['git', 'worktree', 'remove', worktreePath])
const result = await runCliCommand(['git', 'worktree', 'remove', worktreePath])

if (result.exitCode !== 0) {
throw new Error(`Failed to remove worktree: ${result.stderr.trim()}`)
Expand Down
88 changes: 21 additions & 67 deletions src/lib/git-workflow.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DevelopOptions } from '../types'
import { getGhCommand } from './gh-command'
import { runCliCommand } from './git-exec'

// Re-export worktree functions from dedicated module
export {
Expand All @@ -17,21 +18,8 @@
repo?: string,
): Promise<string[]> {
if (!repo) {
// Try to get from current git repo
const proc = Bun.spawn(['git', 'rev-parse', '--show-toplevel'], {
stdout: 'pipe',
stderr: 'pipe',
})
await new Response(proc.stdout).text()
const exitCode = await proc.exited

if (exitCode !== 0) {
// Cannot determine repo, return empty array
return []
}

// For now, if no repo specified and can't auto-detect owner/repo, return empty
// This is a limitation of the current implementation
// For now, if no repo specified, return empty array
// This is a limitation of the current implementation - auto-detection not yet implemented
return []
}

Expand Down Expand Up @@ -72,25 +60,18 @@
`issueNumber=${issueNumber}`,
]

const proc = Bun.spawn(args, {
stdout: 'pipe',
stderr: 'pipe',
})
const result = await runCliCommand(args)

const output = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited

if (exitCode !== 0) {
if (result.exitCode !== 0) {
// GraphQL query failed
if (stderr.trim()) {
console.warn(`⚠️ Failed to get linked branches: ${stderr.trim()}`)
if (result.stderr.trim()) {
console.warn(`⚠️ Failed to get linked branches: ${result.stderr.trim()}`)
}
return []
}

try {
const data = JSON.parse(output)
const data = JSON.parse(result.stdout)
const branches = data?.data?.repository?.issue?.linkedBranches?.nodes || []
return branches
.map((node: unknown) => {
Expand Down Expand Up @@ -142,28 +123,21 @@
args.push('-n', options.name)
}

const proc = Bun.spawn(args, {
stdout: 'pipe',
stderr: 'pipe',
})

const output = await new Response(proc.stdout).text()
const exitCode = await proc.exited
const result = await runCliCommand(args)

if (exitCode !== 0) {
const error = await new Response(proc.stderr).text()
throw new Error(`Failed to develop issue: ${error.trim()}`)
if (result.exitCode !== 0) {
throw new Error(`Failed to develop issue: ${result.stderr.trim()}`)
}

// Parse branch name from gh issue develop output
// With --checkout: "Switched to a new branch 'feat-123-title'"
const branchMatch = output.match(/'([^']+)'/)
const branchMatch = result.stdout.match(/'([^']+)'/)

Check warning on line 134 in src/lib/git-workflow.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=pleaseai_gh-please&issues=AZ8ejJLYBdGAKGONA-kR&open=AZ8ejJLYBdGAKGONA-kR&pullRequest=272
if (branchMatch) {
return branchMatch[1]!
}

// Without --checkout: "github.com/owner/repo/tree/branch-name"
const urlMatch = output.match(/\/tree\/(\S+)/)
const urlMatch = result.stdout.match(/\/tree\/(\S+)/)

Check warning on line 140 in src/lib/git-workflow.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=pleaseai_gh-please&issues=AZ8ejJLYBdGAKGONA-kS&open=AZ8ejJLYBdGAKGONA-kS&pullRequest=272
if (urlMatch) {
return urlMatch[1]!.trim()
}
Expand All @@ -188,48 +162,28 @@
branch: string,
): Promise<void> {
// Step 1: Fetch to remote tracking branch (refs/remotes/origin/{branch})
const fetchProc = Bun.spawn(
const fetchResult = await runCliCommand(
['git', '-C', bareRepoPath, 'fetch', 'origin', `${branch}:refs/remotes/origin/${branch}`],
{
stdout: 'pipe',
stderr: 'pipe',
},
)

const fetchExitCode = await fetchProc.exited

if (fetchExitCode !== 0) {
const error = await new Response(fetchProc.stderr).text()
throw new Error(`Failed to fetch branch: ${error.trim()}`)
if (fetchResult.exitCode !== 0) {
throw new Error(`Failed to fetch branch: ${fetchResult.stderr.trim()}`)
}

// Step 2: Create or update local branch from remote tracking branch
// Try to create the branch first
const createProc = Bun.spawn(
const createResult = await runCliCommand(
['git', '-C', bareRepoPath, 'branch', branch, `refs/remotes/origin/${branch}`],
{
stdout: 'pipe',
stderr: 'pipe',
},
)

const createExitCode = await createProc.exited

if (createExitCode !== 0) {
if (createResult.exitCode !== 0) {
// Branch might already exist, try to update it
const updateProc = Bun.spawn(
const updateResult = await runCliCommand(
['git', '-C', bareRepoPath, 'branch', '-f', branch, `refs/remotes/origin/${branch}`],
{
stdout: 'pipe',
stderr: 'pipe',
},
)

const updateExitCode = await updateProc.exited

if (updateExitCode !== 0) {
const error = await new Response(updateProc.stderr).text()
throw new Error(`Failed to update local branch: ${error.trim()}`)
if (updateResult.exitCode !== 0) {
throw new Error(`Failed to update local branch: ${updateResult.stderr.trim()}`)
}
}
}
78 changes: 37 additions & 41 deletions src/lib/repo-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { getGhCommand } from './gh-command'
import { runCliCommand } from './git-exec'

/**
* Parse repository string in format "owner/repo" or GitHub URL
Expand Down Expand Up @@ -51,35 +52,32 @@ export async function findBareRepo(owner: string, repo: string): Promise<string
* Check if current directory is inside a git repository
*/
export async function isInGitRepo(): Promise<boolean> {
const proc = Bun.spawn(['git', 'rev-parse', '--git-dir'], {
stdout: 'pipe',
stderr: 'pipe',
})

const exitCode = await proc.exited
return exitCode === 0
try {
const result = await runCliCommand(['git', 'rev-parse', '--git-dir'])
return result.exitCode === 0
}
catch {
return false
}
}

/**
* Get the .git directory of the current repository
* Returns absolute path if in a git repo, null otherwise
*/
export async function getGitDir(): Promise<string | null> {
const proc = Bun.spawn(['git', 'rev-parse', '--absolute-git-dir'], {
stdout: 'pipe',
stderr: 'pipe',
})
try {
const result = await runCliCommand(['git', 'rev-parse', '--absolute-git-dir'])

const output = await new Response(proc.stdout).text()
const exitCode = await proc.exited
if (result.exitCode !== 0) {
return null
}

if (exitCode !== 0) {
// Consume stderr to prevent potential pipe issues
await new Response(proc.stderr).text()
return result.stdout.trim()
}
catch {
return null
}

return output.trim()
}

/**
Expand All @@ -98,19 +96,18 @@ export async function cloneBareRepo(owner: string, repo: string): Promise<string
}

// Clone as bare repository
const proc = Bun.spawn(
[getGhCommand(), 'repo', 'clone', `${owner}/${repo}`, bareRepoPath, '--', '--bare'],
{
stdout: 'pipe',
stderr: 'pipe',
},
)

const exitCode = await proc.exited
let result
try {
result = await runCliCommand(
[getGhCommand(), 'repo', 'clone', `${owner}/${repo}`, bareRepoPath, '--', '--bare'],
)
}
catch (error) {
throw new Error(`Failed to clone repository: ${error instanceof Error ? error.message : String(error)}`)
}

if (exitCode !== 0) {
const error = await new Response(proc.stderr).text()
throw new Error(`Failed to clone repository: ${error.trim()}`)
if (result.exitCode !== 0) {
throw new Error(`Failed to clone repository: ${result.stderr.trim()}`)
}

return bareRepoPath
Expand All @@ -120,20 +117,19 @@ export async function cloneBareRepo(owner: string, repo: string): Promise<string
* Get current repository information (owner/repo from gh CLI)
*/
async function getCurrentRepoInfo(): Promise<{ owner: string, repo: string }> {
const proc = Bun.spawn([getGhCommand(), 'repo', 'view', '--json', 'owner,name'], {
stdout: 'pipe',
stderr: 'pipe',
})

const output = await new Response(proc.stdout).text()
const exitCode = await proc.exited
let result
try {
result = await runCliCommand([getGhCommand(), 'repo', 'view', '--json', 'owner,name'])
}
catch (error) {
throw new Error(`Failed to get repository info: ${error instanceof Error ? error.message : String(error)}`)
}

if (exitCode !== 0) {
const error = await new Response(proc.stderr).text()
throw new Error(`Failed to get repository info: ${error.trim() || 'Not in a git repository'}`)
if (result.exitCode !== 0) {
throw new Error(`Failed to get repository info: ${result.stderr.trim() || 'Not in a git repository'}`)
}

const data = JSON.parse(output)
const data = JSON.parse(result.stdout)
return {
owner: data.owner?.login || data.owner,
repo: data.name,
Expand Down
2 changes: 1 addition & 1 deletion test/lib/git-workflow-worktree.preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const mockMkdir = mock(() => Promise.resolve())

// Mock git-exec module BEFORE it's imported
mock.module('../../src/lib/git-exec', () => ({
runGitCommand: mockRunGitCommand,
runCliCommand: mockRunGitCommand,
warnWithFollowup: mockWarnWithFollowup,
}))

Expand Down
Loading
Loading