Skip to content

Commit 0cd1e52

Browse files
tonychang04claude
andauthored
feat: link once — git-style walk-up resolution for .insta/project.json (#23)
readProject resolves the nearest ancestor link, so every command works from any subdirectory of a linked project; writeProject targets the found root (branch switch in src/ no longer mints a nested link). Fresh links in unlinked trees still bind cwd. Claude-Session: https://claude.ai/code/session_01GMbAe5K1RfwaAcge5inX7P Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 674433d commit 0cd1e52

2 files changed

Lines changed: 60 additions & 4 deletions

File tree

src/config.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// CLI config: global (~/.insta/config.json: api url + tokens) and per-project (./.insta/project.json).
22
import { homedir } from 'node:os'
3-
import { join } from 'node:path'
3+
import { dirname, join, resolve } from 'node:path'
44
import { mkdir, readFile, writeFile } from 'node:fs/promises'
55

66
const GLOBAL_DIR = join(homedir(), '.insta')
@@ -37,15 +37,35 @@ export async function writeGlobal(c: GlobalConfig): Promise<void> {
3737
await writeFile(GLOBAL_FILE, JSON.stringify(c, null, 2))
3838
}
3939

40+
/** Git-style ancestor lookup: the nearest directory at-or-above `cwd` containing
41+
* .insta/project.json — so "link once" works from any subdirectory of the project. */
42+
export async function findProjectRoot(cwd = process.cwd()): Promise<string | null> {
43+
let dir = resolve(cwd)
44+
for (;;) {
45+
try {
46+
await readFile(join(dir, PROJECT_DIR, PROJECT_FILE), 'utf8')
47+
return dir
48+
} catch { /* keep climbing */ }
49+
const parent = dirname(dir)
50+
if (parent === dir) return null // filesystem root
51+
dir = parent
52+
}
53+
}
54+
4055
export async function readProject(cwd = process.cwd()): Promise<ProjectConfig | null> {
56+
const root = await findProjectRoot(cwd)
57+
if (!root) return null
4158
try {
42-
return JSON.parse(await readFile(join(cwd, PROJECT_DIR, PROJECT_FILE), 'utf8')) as ProjectConfig
59+
return JSON.parse(await readFile(join(root, PROJECT_DIR, PROJECT_FILE), 'utf8')) as ProjectConfig
4360
} catch {
4461
return null
4562
}
4663
}
4764

65+
/** Writes to the existing project root when inside a linked project (branch switches from a
66+
* subdirectory must not mint a nested link); a fresh `link` in an unlinked tree writes to cwd. */
4867
export async function writeProject(c: ProjectConfig, cwd = process.cwd()): Promise<void> {
49-
await mkdir(join(cwd, PROJECT_DIR), { recursive: true })
50-
await writeFile(join(cwd, PROJECT_DIR, PROJECT_FILE), JSON.stringify(c, null, 2))
68+
const target = (await findProjectRoot(cwd)) ?? cwd
69+
await mkdir(join(target, PROJECT_DIR), { recursive: true })
70+
await writeFile(join(target, PROJECT_DIR, PROJECT_FILE), JSON.stringify(c, null, 2))
5171
}

test/project-link.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// "Link once and it works" requires git-style ancestor lookup: commands run from any
2+
// subdirectory of a linked project must resolve the SAME link, and updates (branch switch)
3+
// must rewrite the link at the project root — never mint a nested .insta in the subdir.
4+
import { test, expect } from 'vitest'
5+
import { mkdtempSync, mkdirSync, existsSync } from 'node:fs'
6+
import { tmpdir } from 'node:os'
7+
import { join } from 'node:path'
8+
import { readProject, writeProject } from '../src/config.js'
9+
10+
const proj = { projectId: 'p-1', orgId: 'o-1', branch: 'main' }
11+
12+
function linkedProjectWithSubdir(): { root: string; sub: string } {
13+
const root = mkdtempSync(join(tmpdir(), 'insta-link-'))
14+
const sub = join(root, 'src', 'deep')
15+
mkdirSync(sub, { recursive: true })
16+
return { root, sub }
17+
}
18+
19+
test('readProject finds the link from a nested subdirectory', async () => {
20+
const { root, sub } = linkedProjectWithSubdir()
21+
await writeProject(proj, root)
22+
expect(await readProject(sub)).toMatchObject({ projectId: 'p-1' })
23+
})
24+
25+
test('writeProject from a subdirectory updates the root link, not a nested copy', async () => {
26+
const { root, sub } = linkedProjectWithSubdir()
27+
await writeProject(proj, root)
28+
await writeProject({ ...proj, branch: 'feat' }, sub) // e.g. `insta branch switch feat` run in src/deep
29+
expect((await readProject(root))?.branch).toBe('feat')
30+
expect(existsSync(join(sub, '.insta'))).toBe(false) // no second link minted
31+
})
32+
33+
test('unlinked directories still resolve to null (walk stops at fs root)', async () => {
34+
const lone = mkdtempSync(join(tmpdir(), 'insta-unlinked-'))
35+
expect(await readProject(lone)).toBeNull()
36+
})

0 commit comments

Comments
 (0)