Skip to content
Open
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ freebuff

Then describe what you want. Freebuff finds the relevant files, makes changes, and runs the checks that matter for your project.

**CLI history upgrade:** Chats saved by older versions remain on disk, but need
manual recovery before they appear in `/history` or can be continued. See
[CLI chat history](./docs/cli-chat-history.md#recovering-history-saved-by-older-versions)
for recovery instructions and the reason automatic migration is unsafe.

## Models

Freebuff includes a curated model catalog. The regular picker currently offers:
Expand Down
173 changes: 173 additions & 0 deletions cli/src/__tests__/project-history-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'

import {
getCurrentChatId,
getCurrentChatDir,
getProjectDataDir,
setCurrentChatId,
setProjectRoot,
tryGetProjectRoot,
} from '../project-files'
import { deleteChatSession, getAllChats } from '../utils/chat-history'
import {
loadMostRecentChatState,
saveChatState,
} from '../utils/run-state-storage'

import type { ChatMessage } from '../types/chat'
import type { RunState } from '@codebuff/sdk'

describe('project history isolation', () => {
let tempRoot: string
let projectA: string
let projectB: string
let previousConfigDir: string | undefined
let previousProjectRoot: string | undefined
let previousChatId: string

beforeEach(() => {
previousConfigDir = process.env.FREEBUFF_CONFIG_DIR
previousProjectRoot = tryGetProjectRoot()
previousChatId = getCurrentChatId()
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'project-history-'))
process.env.FREEBUFF_CONFIG_DIR = path.join(tempRoot, 'config')
projectA = path.join(tempRoot, 'client-a', 'app')
projectB = path.join(tempRoot, 'client-b', 'app')
fs.mkdirSync(projectA, { recursive: true })
fs.mkdirSync(projectB, { recursive: true })
})

afterEach(() => {
if (previousConfigDir === undefined) delete process.env.FREEBUFF_CONFIG_DIR
else process.env.FREEBUFF_CONFIG_DIR = previousConfigDir
setProjectRoot(previousProjectRoot ?? process.cwd())
setCurrentChatId(previousChatId)
fs.rmSync(tempRoot, { recursive: true, force: true })
})

function saveChat(chatId: string, content: string) {
setCurrentChatId(chatId)
const runState = {
output: { type: 'error', message: content },
} as RunState
const messages: ChatMessage[] = [
{
id: chatId,
variant: 'user',
content,
timestamp: new Date().toISOString(),
},
]
saveChatState(runState, messages, getCurrentChatDir())
}

test('continuing a same-named project does not restore another project', () => {
setProjectRoot(projectA)
saveChat('a-chat', 'client A context')
const dataDirA = getProjectDataDir()

setProjectRoot(projectB)
expect(loadMostRecentChatState()).toBeNull()
expect(getAllChats()).toEqual([])
expect(getProjectDataDir()).not.toBe(dataDirA)

saveChat('b-chat', 'client B context')
expect(loadMostRecentChatState()?.messages[0]?.content).toBe(
'client B context',
)

setProjectRoot(projectA)
expect(loadMostRecentChatState()?.chatId).toBe('a-chat')
expect(loadMostRecentChatState()?.runState.output).toEqual({
type: 'error',
message: 'client A context',
})
expect(getAllChats().map((chat) => chat.chatId)).toEqual(['a-chat'])
})

test('matching chat IDs cannot overwrite or delete another project history', () => {
setProjectRoot(projectA)
saveChat('same-chat', 'client A context')
setProjectRoot(projectB)
saveChat('same-chat', 'client B context')
setProjectRoot(projectA)
expect(loadMostRecentChatState()?.messages[0]?.content).toBe(
'client A context',
)
setProjectRoot(projectB)
expect(deleteChatSession('same-chat')).toBe(true)
setProjectRoot(projectA)
expect(loadMostRecentChatState()?.messages[0]?.content).toBe(
'client A context',
)
})

test('equivalent absolute paths retain the same history', () => {
setProjectRoot(projectA)
saveChat('a-chat', 'client A context')
const dataDir = getProjectDataDir()
setProjectRoot(path.join(projectA, '..', 'app'))
expect(getProjectDataDir()).toBe(dataDir)
expect(loadMostRecentChatState()?.chatId).toBe('a-chat')
})

test('unowned legacy history is preserved without automatic restoration', () => {
const legacyDir = path.join(
process.env.FREEBUFF_CONFIG_DIR!,
'projects',
'app',
'chats',
'legacy-chat',
)
fs.mkdirSync(legacyDir, { recursive: true })
fs.writeFileSync(
path.join(legacyDir, 'chat-messages.json'),
JSON.stringify([
{ id: 'legacy', variant: 'user', content: 'unknown project context' },
]),
)
const original = fs.readFileSync(
path.join(legacyDir, 'chat-messages.json'),
'utf8',
)
for (const project of [projectA, projectB]) {
setProjectRoot(project)
expect(loadMostRecentChatState()).toBeNull()
expect(loadMostRecentChatState('legacy-chat')).toBeNull()
saveChat('new-chat', 'new context')
}
expect(
fs.readFileSync(path.join(legacyDir, 'chat-messages.json'), 'utf8'),
).toBe(original)
})

test('a user-selected legacy chat can be copied into its project history', () => {
setProjectRoot(projectA)
saveChat('verified-chat', 'client A context')
const legacyDir = path.join(
process.env.FREEBUFF_CONFIG_DIR!,
'projects',
'app',
'chats',
'verified-chat',
)
fs.mkdirSync(path.dirname(legacyDir), { recursive: true })
fs.renameSync(getCurrentChatDir(), legacyDir)
expect(loadMostRecentChatState()).toBeNull()

fs.cpSync(legacyDir, getCurrentChatDir(), {
recursive: true,
force: false,
errorOnExist: true,
})
expect(loadMostRecentChatState()?.messages[0]?.content).toBe(
'client A context',
)
setProjectRoot(projectB)
expect(loadMostRecentChatState()).toBeNull()
expect(fs.existsSync(legacyDir)).toBe(true)
})
})
12 changes: 8 additions & 4 deletions cli/src/project-files.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { mkdirSync, readdirSync, statSync } from 'fs'
import path from 'path'
import { createHash } from 'node:crypto'

import { getConfigDir } from './utils/auth'
import { IS_FREEBUFF } from './utils/constants'
Expand Down Expand Up @@ -57,10 +58,13 @@ export function getProjectDataDir(): string {
throw new Error('Project root not set')
}

const baseName = path.basename(root)
const baseDir = path.join(getConfigDir(), 'projects', baseName)

return baseDir
// Folder names are not project identities: unrelated checkouts named
// "app" must not share transcripts or resumed agent state. Keep a separate
// namespace so new keys cannot collide with legacy basename directories.
const projectKey = createHash('sha256')
.update(path.resolve(root))
.digest('hex')
return path.join(getConfigDir(), 'projects', 'by-path', projectKey)
}

/**
Expand Down
67 changes: 67 additions & 0 deletions docs/cli-chat-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Project chat history

The CLI keeps each project's saved chats under
`<config-dir>/projects/by-path/<project-key>/chats/`. The project key is the
SHA-256 hash of the full absolute project path, resolved with Node's
`path.resolve`. Different checkouts have separate histories even when their
folder names match. Selecting the same path again restores its history.

`path.resolve` normalizes relative paths and `..`, but does not resolve symlinks.
Opening the same project through a different symlink path can therefore use a
separate history directory. The key follows the path supplied to the CLI;
it does not merge filesystem aliases.

The production config directory defaults to `~/.config/manicode` on all
platforms. `FREEBUFF_CONFIG_DIR` overrides it with an absolute path;
development and test builds use `manicode-dev` and `manicode-test` respectively.

## Recovering history saved by older versions

**Upgrade behavior:** Previously saved chats will not appear in `/history` or
be available for continuation until you recover them. They have not been deleted.
This behavior change should also be included in the release notes when the fix
is shipped.

Older versions stored chats under `<config-dir>/projects/<folder-name>/chats/`.
That directory could contain chats from several unrelated projects with the
same folder name. New versions leave those files untouched, but do not
automatically restore or migrate them: a saved chat does not reliably identify
which project owns it.

To recover a chat, close the CLI and inspect the old chat directory's
`chat-messages.json`. Only import a chat after confirming it belongs to the
project you want to continue. Keep the original directory as a backup.

Open a terminal in that project's root and run the following with Node. Replace
`CHAT_ID` with the name of the selected old chat directory. If using a
development build or a custom configuration, set `configDir` accordingly.

```js
const fs = require('node:fs')
const path = require('node:path')
const os = require('node:os')
const { createHash } = require('node:crypto')

const configDir =
process.env.FREEBUFF_CONFIG_DIR ||
path.join(os.homedir(), '.config', 'manicode')
const root = path.resolve(process.cwd())
const chatId = 'CHAT_ID'
const key = createHash('sha256').update(root).digest('hex')
const source = path.join(
configDir,
'projects',
path.basename(root),
'chats',
chatId,
)
const target = path.join(configDir, 'projects', 'by-path', key, 'chats', chatId)

fs.mkdirSync(path.dirname(target), { recursive: true })
fs.cpSync(source, target, { recursive: true, force: false, errorOnExist: true })
```

Restart the CLI in that project. The copied chat is now available in `/history`.
Copy individual verified chats rather than the whole legacy history directory.
If copying reports an error, check the source chat ID and paths. An existing
target file is deliberately not overwritten; the original chat remains intact.
Loading