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: 2 additions & 2 deletions src/commands/changelog/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import { getCommitLogCurrentBranch } from '../../lib/simple-git/getCommitLogCurr
import { getCommitLogRangeDetails, CommitDetails } from '../../lib/simple-git/getCommitLogRangeDetails'
import { getCurrentBranchName } from '../../lib/simple-git/getCurrentBranchName'
import { getChangesByCommit } from '../../lib/simple-git/getChangesByCommit'
import { getRepo } from '../../lib/simple-git/getRepo'
import { CommandHandler, FileChange } from '../../lib/types'
import { applyRepoFlag } from '../utils/applyRepoFlag'
import { generateAndReviewLoop } from '../../lib/ui/generateAndReviewLoop'
import { handleResult } from '../../lib/ui/handleResult'
import { LOGO, isInteractive } from '../../lib/ui/helpers'
Expand Down Expand Up @@ -63,8 +63,8 @@ async function processInWaves<T, R>(
}

export const handler: CommandHandler<ChangelogArgv> = async (argv, logger) => {
const git = applyRepoFlag(argv)
const config = loadConfig<ChangelogOptions, ChangelogArgv>(argv)
const git = getRepo()
const key = getApiKeyForModel(config)
const { provider } = getModelAndProviderFromConfig(config)
const changelogService = resolveDynamicService(config, 'changelog')
Expand Down
4 changes: 2 additions & 2 deletions src/commands/commit/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { extractTicketIdFromBranchName } from '../../lib/simple-git/extractTicke
import { getChanges } from '../../lib/simple-git/getChanges'
import { getCurrentBranchName } from '../../lib/simple-git/getCurrentBranchName'
import { getPreviousCommits } from '../../lib/simple-git/getPreviousCommits'
import { getRepo } from '../../lib/simple-git/getRepo'
import { CommandHandler, FileChange } from '../../lib/types'
import { applyRepoFlag } from '../utils/applyRepoFlag'
import { generateAndReviewLoop } from '../../lib/ui/generateAndReviewLoop'
import { handleResult } from '../../lib/ui/handleResult'
import { LOGO, SEPERATOR, isInteractive } from '../../lib/ui/helpers'
Expand All @@ -37,7 +37,7 @@ import { COMMIT_PROMPT, CONVENTIONAL_COMMIT_PROMPT } from './prompt'
import { handleCommitSplit, isCommitSplitCommand } from './split'

export const handler: CommandHandler<CommitArgv> = async (argv, logger) => {
const git = getRepo()
const git = applyRepoFlag(argv)
const config = loadConfig<CommitOptions, CommitArgv>(argv)
const key = getApiKeyForModel(config)
const { provider } = getModelAndProviderFromConfig(config)
Expand Down
15 changes: 2 additions & 13 deletions src/commands/log/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,11 @@ export interface LogOptions extends BaseCommandOptions {
merges?: boolean
noMerges?: boolean
path?: string | string[]
/**
* Repository directory to operate against. When set, log reads
* commit data from this path instead of `process.cwd()`. Mirrors
* the `--repo` flag on `coco ui` so scripts / shell wrappers /
* test scenarios can target arbitrary repos without `cd`-ing.
* `--cwd` is an alias.
*/
repo?: string
since?: string
until?: string
view?: LogView
// `repo` (alias `cwd`) is inherited from BaseCommandOptions — declared
// globally at the yargs root so every subcommand sees it.
}

export type LogArgv = Arguments<LogOptions>
Expand Down Expand Up @@ -81,11 +75,6 @@ export const options = {
description: 'Filter commits by changed path',
type: 'array',
},
repo: {
description: 'Target a specific repository directory instead of the current working directory.',
type: 'string',
alias: 'cwd',
},
since: {
description: 'Show commits more recent than a date',
type: 'string',
Expand Down
17 changes: 5 additions & 12 deletions src/commands/log/handler.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
import * as path from 'node:path'
import { CommandHandler } from '../../lib/types'
import { Config } from '../../lib/config/types'
import { loadConfig } from '../../lib/config/utils/loadConfig'
import { getRepo } from '../../lib/simple-git/getRepo'
import { handleResult } from '../../lib/ui/handleResult'
import { getCommitDetail, getLogRows } from './data'
import { startCocoUiFromLogArgv } from '../ui/handler'
import { applyRepoFlag } from '../utils/applyRepoFlag'
import { formatCommitDetail, formatLogJson, formatLogTable } from './render'
import { LogArgv } from './config'

export const handler: CommandHandler<LogArgv> = async (argv) => {
// `--repo <dir>` (alias `--cwd`) lets users target an arbitrary
// repository without `cd`-ing first. Mirrors the same flag on
// `coco ui`. chdir up-front so config + git both resolve against
// the same canonical path. Resolve to absolute to avoid the
// confusion of relative paths interacting with the chdir.
if (argv.repo) {
process.chdir(path.resolve(argv.repo))
}

// `--repo <dir>` (alias `--cwd`) — apply the global flag via the
// shared helper. After this returns, `process.cwd()` and the git
// instance are both bound to the targeted repo.
const git = applyRepoFlag(argv)
const config = loadConfig<Config, LogArgv>(argv)
const git = getRepo(argv.repo ? path.resolve(argv.repo) : undefined)
const format = argv.format === 'json' ? 'json' : 'table'

if (argv.commit) {
Expand Down
4 changes: 2 additions & 2 deletions src/commands/recap/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { getPrompt } from '../../lib/langchain/utils/getPrompt'
import { getChanges } from '../../lib/simple-git/getChanges'
import { getChangesByTimestamp } from '../../lib/simple-git/getChangesByTimestamp'
import { getChangesSinceLastTag } from '../../lib/simple-git/getChangesSinceLastTag'
import { getRepo } from '../../lib/simple-git/getRepo'
import { applyRepoFlag } from '../utils/applyRepoFlag'
import { getCurrentBranchName } from '../../lib/simple-git/getCurrentBranchName'
import { getDiffForBranch } from '../../lib/simple-git/getDiffForBranch'
import { CommandHandler } from '../../lib/types'
Expand All @@ -29,7 +29,7 @@ import { fileChangeParser } from '../../lib/parsers/default'
import { createFileChangeParserOptions } from '../../lib/parsers/default/utils/createFileChangeParserOptions'

export const handler: CommandHandler<RecapArgv> = async (argv, logger) => {
const git = getRepo()
const git = applyRepoFlag(argv)
const config = loadConfig<RecapOptions, RecapArgv>(argv)
const key = getApiKeyForModel(config)
const { provider } = getModelAndProviderFromConfig(config)
Expand Down
4 changes: 2 additions & 2 deletions src/commands/review/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import { fileChangeParser } from '../../lib/parsers/default/index'
import { createFileChangeParserOptions } from '../../lib/parsers/default/utils/createFileChangeParserOptions'
import { getChanges } from '../../lib/simple-git/getChanges'
import { getDiffForBranch } from '../../lib/simple-git/getDiffForBranch'
import { getRepo } from '../../lib/simple-git/getRepo'
import { getCurrentBranchName } from '../../lib/simple-git/getCurrentBranchName'
import { CommandHandler } from '../../lib/types'
import { applyRepoFlag } from '../utils/applyRepoFlag'
import { generateAndReviewLoop } from '../../lib/ui/generateAndReviewLoop'
import { isInteractive, LOGO, severityColor } from '../../lib/ui/helpers'
import { TaskList } from '../../lib/ui/TaskList'
Expand All @@ -35,7 +35,7 @@ const ReviewFeedbackResponseSchema = z.preprocess(
)

export const handler: CommandHandler<ReviewArgv> = async (argv, logger) => {
const git = getRepo()
const git = applyRepoFlag(argv)
const config = loadConfig<ReviewOptions, ReviewArgv>(argv)
const key = getApiKeyForModel(config)
const { provider } = getModelAndProviderFromConfig(config)
Expand Down
15 changes: 15 additions & 0 deletions src/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ export interface BaseArgvOptions {
verbose: boolean
version: boolean
help: boolean
/**
* Repository directory to operate against. When set, the command
* chdir's to this path before loading config / opening a git
* instance, so every downstream read (config lookup, simple-git
* baseDir, commitlint discovery, etc.) sees the same root.
*
* `--cwd` is an alias.
*
* Inherited by every coco subcommand so scripts / editor wrappers
* / scenario tests can target arbitrary repos without `cd`-ing
* first. Defaults to `process.cwd()` when omitted (unchanged
* behavior for users who launch via the regular `cd && coco ...`
* path).
*/
repo?: string
}

export interface BaseCommandOptions extends BaseArgvOptions {}
Expand Down
17 changes: 2 additions & 15 deletions src/commands/ui/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,10 @@ export interface UiOptions extends BaseCommandOptions {
branch?: string
limit?: number
path?: string | string[]
/**
* Repository directory to operate against. When set, the workstation
* binds its git instance + cwd reads to this path instead of
* `process.cwd()`. Lets users / scripts / scenarios launch coco
* against arbitrary repos without a leading `cd`.
*
* `--cwd` is exposed as an alias since users reaching for this
* flag often think "change directory" before "target this repo."
*/
repo?: string
theme?: LogInkThemePreset
view?: UiView
// `repo` (alias `cwd`) is inherited from BaseCommandOptions — declared
// globally at the yargs root so every subcommand sees it.
}

export type UiArgv = Arguments<UiOptions>
Expand Down Expand Up @@ -53,11 +45,6 @@ export const options = {
description: 'Filter history by changed path',
type: 'array',
},
repo: {
description: 'Target a specific repository directory instead of the current working directory.',
type: 'string',
alias: 'cwd',
},
theme: {
description: 'TUI theme preset',
choices: ['default', 'monochrome', 'catppuccin', 'gruvbox'],
Expand Down
29 changes: 7 additions & 22 deletions src/commands/ui/handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as path from 'node:path'
import { Arguments } from 'yargs'
import { SimpleGit } from 'simple-git'
import { CommandHandler } from '../../lib/types'
Expand All @@ -8,6 +7,7 @@ import { getRepo } from '../../lib/simple-git/getRepo'
import { LogArgv, LogOptions } from '../log/config'
import { GitLogRow, getLogRows } from '../log/data'
import { startInkInteractiveLog } from '../log/inkRuntime'
import { applyRepoFlag } from '../utils/applyRepoFlag'
import { readCachedCommits, writeCachedCommits } from '../../workstation/chrome/overviewCache'
import { LogInkThemeConfig } from '../../workstation/chrome/theme'
import { UiArgv } from './config'
Expand Down Expand Up @@ -100,29 +100,14 @@ export async function startCocoUiFromLogArgv(
}

export async function startCocoUi(argv: UiArgv): Promise<void> {
// `--repo <dir>` (alias `--cwd`) lets users target an arbitrary
// repository without `cd`-ing first. Resolve to absolute up-front
// so:
// 1. process.chdir gets a stable path (relative paths against
// the original cwd would surprise the user if any later code
// reads cwd after the chdir).
// 2. The disk cache key (rooted at repoPath) is canonical.
// 3. simple-git's baseDir is unambiguous.
// When the flag is omitted, fall back to process.cwd() — original
// behavior, no surprise for users who launch via `cd && coco ui`.
const repoPath = argv.repo ? path.resolve(argv.repo) : process.cwd()

// chdir BEFORE loadConfig so .coco.config.json lookup walks up
// from the targeted repo, not from wherever the user ran coco
// from. Many config-resolution paths (lib/utils/findUp, etc.)
// read process.cwd() — keeping them honest means doing the chdir
// up-front rather than passing a path everywhere.
if (argv.repo) {
process.chdir(repoPath)
}
// `--repo <dir>` (alias `--cwd`) — apply the global flag via the
// shared helper. After this returns, `process.cwd()` and the git
// instance are both bound to the targeted repo, so loadConfig
// walks the right tree and downstream reads stay consistent.
const git = applyRepoFlag(argv)
const repoPath = process.cwd()

const config = loadConfig<Config, UiArgv>(argv)
const git = getRepo(repoPath)
const logArgv = createLogArgvFromUiArgv(argv)

// Same three-stage boot as startCocoUiFromLogArgv — mount with
Expand Down
68 changes: 68 additions & 0 deletions src/commands/utils/applyRepoFlag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { applyRepoFlag } from './applyRepoFlag'

jest.mock('../../lib/simple-git/getRepo', () => ({
getRepo: jest.fn(),
}))

import { getRepo } from '../../lib/simple-git/getRepo'

const mockedGetRepo = getRepo as jest.MockedFunction<typeof getRepo>

describe('applyRepoFlag', () => {
let originalCwd: string
let chdirSpy: jest.SpyInstance

beforeEach(() => {
originalCwd = process.cwd()
chdirSpy = jest.spyOn(process, 'chdir').mockImplementation(() => undefined)
mockedGetRepo.mockReset()
mockedGetRepo.mockReturnValue({} as ReturnType<typeof getRepo>)
})

afterEach(() => {
chdirSpy.mockRestore()
// Reset cwd in case the real chdir slipped through.
if (process.cwd() !== originalCwd) {
try { process.chdir(originalCwd) } catch { /* noop */ }
}
})

it('returns getRepo() (no baseDir) when --repo is omitted', () => {
applyRepoFlag({ repo: undefined })

expect(mockedGetRepo).toHaveBeenCalledWith()
expect(chdirSpy).not.toHaveBeenCalled()
})

it('returns getRepo(absolutePath) and chdirs when --repo is set', () => {
applyRepoFlag({ repo: '/tmp/some-repo' })

expect(chdirSpy).toHaveBeenCalledTimes(1)
expect(chdirSpy).toHaveBeenCalledWith('/tmp/some-repo')
expect(mockedGetRepo).toHaveBeenCalledWith('/tmp/some-repo')
})

it('resolves a relative --repo path to absolute before chdir', () => {
// Whatever cwd we're running in, a './fixture' relative path
// should be resolved against it. We assert only that the chdir
// target is absolute — testing the resolved value end-to-end
// ties this to the runner's cwd, which is brittle.
applyRepoFlag({ repo: './fixture' })

expect(chdirSpy).toHaveBeenCalledTimes(1)
const target = chdirSpy.mock.calls[0][0] as string
expect(target.startsWith('/')).toBe(true)
expect(target.endsWith('/fixture')).toBe(true)
expect(mockedGetRepo).toHaveBeenCalledWith(target)
})

it('does not chdir when --repo is an empty string', () => {
// Empty string is treated the same as omitted — defensive guard
// since yargs can produce `''` for flags that were set without
// a value in some edge cases.
applyRepoFlag({ repo: '' })

expect(mockedGetRepo).toHaveBeenCalledWith()
expect(chdirSpy).not.toHaveBeenCalled()
})
})
40 changes: 40 additions & 0 deletions src/commands/utils/applyRepoFlag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as path from 'node:path'
import { getRepo } from '../../lib/simple-git/getRepo'
import { BaseArgvOptions } from '../types'

/**
* Apply the global `--repo <dir>` (alias `--cwd`) flag for any
* command handler. Returns the bound simple-git instance.
*
* Behavior:
* - When `argv.repo` is set, resolves the path to absolute,
* `process.chdir`s up-front, and returns `getRepo(repoPath)`.
* - When omitted, returns `getRepo()` (defaults to cwd) — original
* behavior, no surprise for users on the `cd && coco ...` path.
*
* Why chdir up-front:
* Many config / discovery paths (loadConfig's findUp for
* `.coco.config.json`, commitlint config detection, etc.) read
* `process.cwd()` directly. If we only changed simple-git's
* baseDir without chdir-ing, those would resolve against the
* original cwd — leading to "coco is reading this repo but
* loading config from somewhere else" surprises.
*
* Returns the SimpleGit instance so callers can use it directly:
*
* ```ts
* export const handler: CommandHandler<CommitArgv> = async (argv) => {
* const git = applyRepoFlag(argv)
* const config = loadConfig(argv)
* // ... rest of handler uses git + config
* }
* ```
*/
export function applyRepoFlag(argv: Pick<BaseArgvOptions, 'repo'>): ReturnType<typeof getRepo> {
if (!argv.repo) {
return getRepo()
}
const repoPath = path.resolve(argv.repo)
process.chdir(repoPath)
return getRepo(repoPath)
}
13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ const y = yargs()

y.scriptName('coco')

// Global `--repo <dir>` (alias `--cwd`) — every subcommand inherits
// it. The shared `applyRepoFlag` helper handlers call up-front
// chdir's to this directory + binds the simple-git instance so every
// downstream read (config lookup, simple-git baseDir, commitlint
// discovery) sees the same root. Lets users / scripts / editor
// integrations target arbitrary repos without `cd`-ing first.
y.option('repo', {
type: 'string',
alias: 'cwd',
description: 'Target a specific repository directory instead of the current working directory.',
global: true,
})

y.command<CommitOptions>(
[commit.command, '$0'],
commit.desc,
Expand Down
Loading