Skip to content

feat(core): project copying and tracking paths#30139

Draft
jlongster wants to merge 2 commits into
devfrom
jlongster/project-paths
Draft

feat(core): project copying and tracking paths#30139
jlongster wants to merge 2 commits into
devfrom
jlongster/project-paths

Conversation

@jlongster
Copy link
Copy Markdown
Contributor

@jlongster jlongster commented May 31, 2026

Project Paths And Copies

Reference for the implemented local project-path and project-copy system: responsibilities, dependencies, API shapes, errors, and call flows.

Overview

Project.resolve(directory)
|- Git: identify the project and canonical checkout root
`- Existing project-open flow: persist project and remember opened path

Project.paths(projectID)
`- ProjectPathTable: read known local paths only

ProjectCopy
|- ProjectPathTable: read primary path; insert/remove discovered or managed copies
|- GitWorktree strategy: implement the initial copy mechanism
|  `- Git: create/remove/list linked checkouts
`- EventV2: publish project.paths.updated after stored paths change

HTTP / SDK
|- project.paths -> Project.paths
`- projectCopy.* -> ProjectCopy.*

Core Domain

Project

Source: packages/core/src/project.ts

Responsibility: resolve project identity and read known local filesystem roots for a project.

Dependencies

  • Git
    • Used by Project.resolve(...) to inspect the repository, normalized remote, and root commits.
  • ProjectPathTable
    • Used by Project.paths(...) as the persisted read model.
  • Database
    • Executes the project_path query.

Methods

Method Input Return Value Errors
resolve AbsolutePath Resolved project identity and canonical directory No declared domain errors
paths { projectID: Project.ID } Array<{ path: AbsolutePath; primary: boolean }> No declared domain errors
commit { store: AbsolutePath; id: Project.ID } void No declared domain errors

Project.resolve

  • Calls Git.find(directory).

    • For a non-Git location, returns:

      {
        id: Project.ID.global
        directory: AbsolutePath // filesystem root
        vcs: undefined
      }
    • For a Git checkout, calls Git.remote(repo) and Git.roots(repo) to derive stable identity.

  • Return value for a Git checkout:

    {
      previous?: Project.ID
      id: Project.ID
      directory: AbsolutePath // canonical checkout root
      vcs: { type: "git"; store: AbsolutePath }
    }
  • Error behavior: Git inspection failure degrades to missing identity inputs rather than producing a declared failure channel.

Project.paths

  • Reads only rows from project_path matching projectID.
  • Sorts by absolute path.
  • Brands persisted strings as AbsolutePath at the service boundary.
  • Does not invoke ProjectCopy, Git discovery, pruning, or copy creation.
project.paths({ projectID })
// Effect<Array<{ path: AbsolutePath; primary: boolean }>>

ProjectPathTable

Source: packages/core/src/project/path.sql.ts

Responsibility: store known local materializations of projects.

Stored Shape

{
  project_id: Project.ID
  path: string // canonical absolute checkout root
  primary: boolean
  time_created: number
  time_updated: number
}

Constraints

  • Foreign key to ProjectTable
    • Deleting a project cascades deletion to its path rows.
  • Composite primary key: (project_id, path)
    • The same local path cannot be stored twice for one project.
  • Partial unique index on project_id where primary = true
    • One project may have many paths but at most one preferred path.

Writers And Readers

  • Read by Project.paths
    • Returns the public known-path list.
  • Written by persisted project opening
    • Remembers paths opened directly by the application.
  • Written and deleted by ProjectCopy
    • Reflects created, removed, and explicitly discovered copies.

Persisted Project Opening Flow

Source: packages/opencode/src/project/project.ts

Responsibility: open a directory in the application and persist legacy project state plus its active local path.

Call Flow

Project.fromDirectory(directory)
|- calls core Project.resolve(directory)
|- persists/updates application project record
|- updates existing session/project compatibility data
`- calls rememberProjectPath({ projectID, path: resolvedDirectory })

rememberProjectPath

  • Skips Project.ID.global.
  • Inserts the currently opened canonical checkout path if it is not already present.
  • If no primary path exists for this project ID, marks this path primary.
  • If another primary exists, inserts this path as secondary.
  • Logs and ignores persistence failures so a path write does not prevent opening a project.

Project ID Change Behavior

  • Existing project_path rows are not migrated when the project ID associated with a checkout changes.
  • After resolution under the new project ID, the active checkout is remembered under that new set of paths.

Copy Management

ProjectCopy

Source: packages/core/src/project/copy.ts

Responsibility: manage and explicitly discover physical local copies while keeping Project.paths(...) read-only.

Dependencies

  • ProjectPathTable
    • Loads the primary source path.
    • Inserts created or discovered paths.
    • Deletes removed or unavailable secondary paths.
  • AppFileSystem
    • Resolves canonical real paths and validates local availability.
  • Strategy registry
    • Dispatches a requested copy operation to a concrete mechanism.
  • EventV2
    • Publishes project.paths.updated only after persisted paths change.

Service API

Method Input Return Value Domain Errors
strategies { projectID } Array<{ id: "git_worktree"; name: string }> None declared
create { projectID, strategy: "git_worktree", path } { path: AbsolutePath } Primary missing, destination exists, path unavailable, Git error
remove { projectID, strategy: "git_worktree", path } void Primary missing, cannot remove primary, path unavailable, Git error
refresh { projectID, strategy?: "git_worktree" } void Primary missing, path unavailable, Git error

Domain Errors

ProjectCopy.PrimaryPathNotFoundError {
  projectID: Project.ID
}

ProjectCopy.CannotRemovePrimaryPathError {
  projectID: Project.ID
  path: AbsolutePath
}

ProjectCopy.DestinationExistsError {
  path: AbsolutePath
}

ProjectCopy.PathUnavailableError {
  path: AbsolutePath
}

Git.WorktreeError {
  operation: "create" | "remove" | "list"
  message: string
  path?: AbsolutePath
  cause?: unknown
}

Create Flow

ProjectCopy.create({ projectID, strategy, path })
|- load the primary ProjectPathTable row
|- canonicalize/validate the primary source path
|- reject if requested destination already exists
|- call selected Strategy.create(...)
|- validate/canonicalize returned created path
|- insert created path as primary = false
`- publish project.paths.updated if inserted

Return value:

{ path: AbsolutePath }

Remove Flow

ProjectCopy.remove({ projectID, strategy, path })
|- load and canonicalize the primary path
|- canonicalize the target path
|- reject removal when target equals primary
|- call selected Strategy.remove(...)
|- delete target path row after successful physical removal
`- publish project.paths.updated if deleted

Return value: void.

Refresh Flow

ProjectCopy.refresh({ projectID, strategy? })
|- load and canonicalize the primary path
|- call one or all registered Strategy.list(...) methods
|- insert newly discovered canonical non-primary paths
|- remove stored non-primary paths only if their local directories no longer exist
`- publish project.paths.updated only if stored rows changed

Return value: void.

Important behavior:

  • Refresh is explicit; Project.paths never initiates it.
  • A separately cloned path which still exists is not removed merely because it is absent from Git worktree enumeration.

ProjectCopy.Event.Updated

Source: packages/core/src/project/copy.ts

Responsibility: notify consumers that Project.paths(...) can now return a changed set.

{
  type: "project.paths.updated"
  data: {
    projectID: Project.ID
  }
}
  • Published after a created or discovered path is inserted.
  • Published after a stored secondary path is removed.
  • Not published when an operation does not change persisted paths.

ProjectCopy.Strategy

Source: packages/core/src/project/copy.ts

Responsibility: define one physical copy mechanism without owning persistence or transport.

interface Strategy {
  id: "git_worktree"
  name: string

  create(input: {
    projectID: Project.ID
    primaryPath: AbsolutePath
    path: AbsolutePath
  }): Effect<{ path: AbsolutePath }, Git.WorktreeError | ProjectCopy.PathUnavailableError>

  remove(input: {
    projectID: Project.ID
    primaryPath: AbsolutePath
    path: AbsolutePath
  }): Effect<void, Git.WorktreeError | ProjectCopy.PathUnavailableError>

  list(input: {
    projectID: Project.ID
    primaryPath: AbsolutePath
  }): Effect<Array<{ path: AbsolutePath }>, Git.WorktreeError | ProjectCopy.PathUnavailableError>
}
  • Called only by ProjectCopy.
  • Does not write ProjectPathTable.
  • Does not set primary.
  • Does not publish events.

Initial Strategy

GitWorktree

Source: packages/core/src/project/copy-strategies.ts

Responsibility: create, remove, and enumerate linked Git worktrees as physical project copies.

Dependencies

  • Git.Service
    • Owns all Git command execution.
  • Canonical-path callback supplied by ProjectCopy
    • Validates and normalizes strategy results before they are persisted.

Methods

  • create({ primaryPath, path })

    • Calls Git.worktreeCreate({ repo, path }).

    • Canonicalizes the created destination.

    • Returns:

      { path: AbsolutePath }
  • remove({ primaryPath, path })

    • Calls Git.worktreeList(repo) to confirm the target is linked.
    • Fails with Git.WorktreeError if the target is not in that list.
    • Calls Git.worktreeRemove({ repo, path }) when confirmed.
    • Returns void.
  • list({ primaryPath })

    • Calls Git.worktreeList(repo).

    • Filters out the primary source path.

    • Canonicalizes each remaining path.

    • Returns:

      Array<{ path: AbsolutePath }>

Errors

  • Git.WorktreeError
  • ProjectCopy.PathUnavailableError

Git Operations

Git

Source: packages/core/src/git.ts

Responsibility: perform Git repository inspection and execute typed worktree commands.

Project Identity Methods

Method Called By Return Value Errors
find(path) Project.resolve Git.Repo | undefined No declared failure
remote(repo, name?) Project.resolve string | undefined No declared failure
roots(repo) Project.resolve string[] No declared failure

Worktree Methods

Method Called By Command Return Value Errors
worktreeCreate({ repo, path }) GitWorktree.create git worktree add --detach <path> HEAD void Git.WorktreeError
worktreeRemove({ repo, path }) GitWorktree.remove git worktree remove --force <path> void Git.WorktreeError
worktreeList(repo) GitWorktree.list/remove git worktree list --porcelain AbsolutePath[] Git.WorktreeError

Error Shape

Git.WorktreeError {
  operation: "create" | "remove" | "list"
  message: string
  path?: AbsolutePath
  cause?: unknown
}

HTTP API

Project Paths Endpoint

Sources:

  • packages/opencode/src/server/routes/instance/httpapi/groups/project.ts
  • packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts
HTTP Method And Path Handler Calls Success Errors
GET /project/:projectID/paths Project.paths({ projectID }) 200 Array<{ path: string; primary: boolean }> No endpoint-specific declared error

Properties:

  • Reads stored paths only.
  • Does not call ProjectCopy.refresh.
  • Does not perform Git discovery.

ProjectCopy Endpoints

Sources:

  • packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts
  • packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts
HTTP Method And Path Payload Handler Calls Success HTTP Errors
GET /project/:projectID/copy/strategy None ProjectCopy.strategies 200 Array<{ id: "git_worktree"; name: string }> Standard request errors only
POST /project/:projectID/copy { strategy: "git_worktree"; path: string } ProjectCopy.create 200 { path: string } Domain failures map to 400
DELETE /project/:projectID/copy { strategy: "git_worktree"; path: string } ProjectCopy.remove 204 No Content Domain failures map to 400
POST /project/:projectID/copy/refresh { strategy?: "git_worktree" } ProjectCopy.refresh 204 No Content Domain failures map to 400

JavaScript SDK

Generated source: packages/sdk/js/src/v2/gen

SDK Method HTTP Route Return Data Errors
sdk.project.paths({ projectID }) GET /project/{projectID}/paths Array<{ path: string; primary: boolean }> Transport/API errors
sdk.projectCopy.strategies({ projectID }) GET /project/{projectID}/copy/strategy Array<{ id: "git_worktree"; name: string }> Transport/API errors
sdk.projectCopy.create({ projectID, strategy, path }) POST /project/{projectID}/copy { path: string } 400 for rejected domain operation
sdk.projectCopy.remove({ projectID, strategy, path }) DELETE /project/{projectID}/copy No content 400 for rejected domain operation
sdk.projectCopy.refresh({ projectID, strategy? }) POST /project/{projectID}/copy/refresh No content 400 for rejected domain operation

End-To-End Flows

Open A Checkout Directly

User opens a local directory
`- Persisted Project Opening Flow
   |- Project.resolve(directory)
   |  `- Git.find / Git.remote / Git.roots
   |- Persist project data
   `- rememberProjectPath(resolved directory)
      `- ProjectPathTable insert/primary selection

Client reads project paths
`- SDK project.paths
   `- HTTP GET /project/:projectID/paths
      `- Project.paths
         `- ProjectPathTable read

Create A Linked Checkout Copy

SDK projectCopy.create({ projectID, strategy: "git_worktree", path })
`- HTTP POST /project/:projectID/copy
   `- ProjectCopy.create
      |- ProjectPathTable: load primary source path
      |- GitWorktree.create
      |  `- Git.worktreeCreate
      |- ProjectPathTable: insert secondary path
      `- EventV2: publish project.paths.updated

Discover A Worktree Created Outside The Application

External command creates a Git worktree

SDK projectCopy.refresh({ projectID, strategy: "git_worktree" })
`- HTTP POST /project/:projectID/copy/refresh
   `- ProjectCopy.refresh
      |- ProjectPathTable: load primary source path
      |- GitWorktree.list
      |  `- Git.worktreeList
      |- ProjectPathTable: insert discovered path(s)
      `- EventV2: publish project.paths.updated when changed

SDK project.paths({ projectID })
`- Returns the refreshed stored path list

Remove A Managed Linked Checkout

SDK projectCopy.remove({ projectID, strategy: "git_worktree", path })
`- HTTP DELETE /project/:projectID/copy
   `- ProjectCopy.remove
      |- ProjectPathTable: load primary source path
      |- Reject if target is primary
      |- GitWorktree.remove
      |  |- Git.worktreeList: verify target is linked
      |  `- Git.worktreeRemove: remove target
      |- ProjectPathTable: delete target path row
      `- EventV2: publish project.paths.updated when changed

@jlongster jlongster force-pushed the jlongster/project-paths branch from e1c3dc0 to 49f3cee Compare May 31, 2026 19:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant