Skip to content

Commit 032da75

Browse files
committed
🌲 Add a named linked checkout inside a Repository
The last slice of #293. `<Worktree>` is a second checkout of the Repository it is written inside, on a branch of its own, and it completes the composition the adversarial workflow in PR #181 is written against: ```md <Repository name="project" url={props.repository} base={props.base}> <Worktree name="implementation" branch={props.branch} as="worktree" /> <Dir path={worktree}>…</Dir> </Repository> ``` Its identity is the enclosing Repository's plus its own name, which is why the Repository comes from lexical context rather than from a prop: a document that could name one would be able to name a Repository not in scope. A missing branch starts from the supplied base, or from the primary checkout's current commit when there is none. A branch the remote published counts as existing, so naming one checks it out rather than recreating it somewhere else. A branch another checkout already holds is refused — nothing is moved, reset or detached to make room. Creation materializes every retained sibling beside the Repository, not only the one being added: Git decides what a repository's worktrees are by reading its own record of them, and a sibling left out would look abandoned. This slice also completes Git's control plane, whose remaining half is the part only a linked worktree has. A worktree's `.git` must be a real regular file; the `.git/worktrees` administration, every slot in it and every `gitdir` pointer must be real entries of their own kind; every retained pointer value must name one place beneath the Workspace root, so relative and traversal-shaped values are refused rather than resolved; and an exported pair's pointers must name each other, with a slot that has no reciprocal refused as incomplete. All of it before any rewriting and before any Git command, because both are what would otherwise trust it. `Git.Switch`, `Git.Add` and `Git.Commit` remain unbuilt and belong to #294. Closes #293
1 parent d540ccb commit 032da75

22 files changed

Lines changed: 1596 additions & 60 deletions

‎architecture.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2081,8 +2081,9 @@ Status is measured against main.
20812081
| `ephemeral eval` | reconstructs live middleware and bindings without a journal entry | built on main |
20822082
| `useWorkflowServiceDenial()` | provides a non-delegating workflow service denial provider, installed inside every start and resume execution scope | built on the #366 stack |
20832083
| `xmd workflow start` / `xmd workflow resume` | starts or resumes a workflow run from the CLI, under the Deno entrypoints only | built on the #366 stack; both acquire #367's executor lock before any lifecycle transition |
2084-
| implicit workflow Workspace | retains provider-neutral filesystem, repository and attachment state by run ID | document filesystem built on the #366 stack; repository, process and attachment capabilities unbuilt (#218) |
2085-
| Repository / Worktree / transactional Git effects | compose named checkouts and publish local mutations with their journal result | defined in `specs/workflow-workspace-spec.md`, unbuilt |
2084+
| implicit workflow Workspace | retains provider-neutral filesystem, repository and attachment state by run ID | document filesystem built on the #366 stack and Repository/Worktree composition on the #293 stack; process capabilities unbuilt (#218) |
2085+
| `<Repository>` / `<Worktree>` / `<Dir>` composition | names a Git repository and its linked checkouts inside the run-owned Workspace, installs each as contextual working directory, and retains creation identity beside the retained Git bytes | built on the #293 stack, Deno provider only |
2086+
| transactional Git effects (`Git.Switch` / `Git.Add` / `Git.Commit`) | publish local Git mutations with their journal result | defined in `specs/workflow-workspace-spec.md`, unbuilt (#294) |
20862087
| workflow lifecycle inspection and control | reads status/list/history without advancing a run, enforces the executor lock, refuses live cancellation, cancels non-live runs under that lock and deletes retained state | built on the #367 stack |
20872088
| historical authored source | retains an authored durable operation's normalized `SourcePosition` beside its identity, and history parses it or refuses the entry | built on the #367 stack |
20882089
| history fork | creates a new run from one compatible checkpoint and retained Workspace root | defined in `specs/workflow-workspace-spec.md`, unbuilt (#368) |

‎packages/workflow/deno.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export { hashRunId, workflowRunLock, workflowRunPath } from "./src/deno/path.ts"
3939
export { APPLICATION_ID, SCHEMA_VERSION } from "./src/deno/schema.ts";
4040
export { withWorkflowWorkspace } from "./src/deno/workspace/host.ts";
4141
export type { WorkflowWorkspaceOptions } from "./src/deno/workspace/host.ts";
42-
export { WORKSPACE_REPOSITORY } from "./src/deno/composition/provider.ts";
42+
export { WORKSPACE_REPOSITORY, WORKSPACE_WORKTREE } from "./src/deno/composition/provider.ts";
4343
export type {
4444
CompositionObserver,
4545
CompositionProviderOptions,

‎packages/workflow/src/composition/api.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* The provider-neutral Repository/Worktree composition Api.
33
*
4-
* Repository components ask this Api for work; the installed
4+
* Repository and Worktree components ask this Api for work; the installed
55
* provider decides how to do it. This package names no subprocess, no host
66
* filesystem and no runtime: a workflow host with the authority to touch a Git
77
* checkout — the Deno host, for now — installs a concrete provider inside its
@@ -23,7 +23,12 @@
2323
import { type Api, createApi } from "@effectionx/context-api";
2424
import type { Operation } from "effection";
2525
import { RepositoryCompositionProviderError } from "./errors.ts";
26-
import type { RepositoryCreationRequest, RepositoryRecord } from "./records.ts";
26+
import type {
27+
RepositoryCreationRequest,
28+
RepositoryRecord,
29+
WorktreeCreationRequest,
30+
WorktreeRecord,
31+
} from "./records.ts";
2732

2833
export interface RepositoryCompositionApi {
2934
/**
@@ -42,6 +47,12 @@ export interface RepositoryCompositionApi {
4247
* Ephemeral: it appends nothing and is performed on every execution.
4348
*/
4449
attachRepository(record: RepositoryRecord): Operation<void>;
50+
51+
/** Create or restore the named Worktree's creation identity, as one durable effect. */
52+
createWorktree(request: WorktreeCreationRequest): Operation<WorktreeRecord>;
53+
54+
/** Rebuild and verify a settled Worktree's live facade. */
55+
attachWorktree(record: WorktreeRecord): Operation<void>;
4556
}
4657

4758
export const RepositoryComposition: Api<RepositoryCompositionApi> =
@@ -54,4 +65,12 @@ export const RepositoryComposition: Api<RepositoryCompositionApi> =
5465
*attachRepository(_record: RepositoryRecord): Operation<void> {
5566
throw new RepositoryCompositionProviderError("<Repository>");
5667
},
68+
// deno-lint-ignore require-yield
69+
*createWorktree(_request: WorktreeCreationRequest): Operation<WorktreeRecord> {
70+
throw new RepositoryCompositionProviderError("<Worktree>");
71+
},
72+
// deno-lint-ignore require-yield
73+
*attachWorktree(_record: WorktreeRecord): Operation<void> {
74+
throw new RepositoryCompositionProviderError("<Worktree>");
75+
},
5776
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* `<Worktree>` — a named linked checkout inside the contextual Repository
3+
* (specs/workflow-workspace-spec.md §6.2).
4+
*
5+
* Invalid without an enclosing lexical `<Repository>`. A Worktree exists inside
6+
* a Repository, and the Repository's name is half of what makes its identity
7+
* durable across replay; threading that name through props would let a document
8+
* name a Repository that is not in scope.
9+
*
10+
* Its two forms match Repository's. The self-closing form is the one the
11+
* adversarial workflow uses, because it is the spelling that both binds a path
12+
* and lets a sibling `<Dir>` render descendants at it:
13+
*
14+
* ```md
15+
* <Repository name="project" url={props.repository}>
16+
* <Worktree name="implementation" branch={props.branch} as="worktree" />
17+
* <Dir path={worktree}>…</Dir>
18+
* </Repository>
19+
* ```
20+
*
21+
* A lexical Worktree written with `as` keeps ordinary generic semantics: its
22+
* rendered descendants are captured and suppressed. It does not bind the
23+
* checkout path while also rendering them, and nothing here makes an exception
24+
* so that it could.
25+
*
26+
* Failure policy is `<Repository>`'s, for the same reason: a refusal this
27+
* recognizes — no Repository in scope, a branch another checkout holds, a name
28+
* already this Repository's for another branch — is described with a fixed
29+
* `WorktreeCompositionError` and then fails the operation it is part of. An
30+
* authored `<PrintErrors>` region decides otherwise, for its whole region.
31+
*/
32+
33+
import { API } from "@executablemd/runtime";
34+
import { content, hasContent } from "@executablemd/core";
35+
import type { Operation } from "effection";
36+
import type { Json } from "@executablemd/durable-streams";
37+
import { RepositoryComposition } from "../api.ts";
38+
import { RepositoryContext } from "../context.ts";
39+
import { WorktreeCompositionError } from "../errors.ts";
40+
41+
export const props = {
42+
type: "object",
43+
properties: {
44+
name: { type: "string", minLength: 1 },
45+
branch: { type: "string", minLength: 1 },
46+
base: { type: "string", minLength: 1 },
47+
},
48+
required: ["name", "branch"],
49+
additionalProperties: false,
50+
};
51+
52+
function required(props: Record<string, Json>, prop: string): string {
53+
const value = props[prop];
54+
if (typeof value !== "string" || value === "") {
55+
throw new WorktreeCompositionError(
56+
typeof props.name === "string" ? props.name : "",
57+
"no-repository-context",
58+
`<Worktree> needs a non-empty ${prop}.`,
59+
);
60+
}
61+
return value;
62+
}
63+
64+
export default function* Worktree(props: Record<string, Json>): Operation<string> {
65+
const name = required(props, "name");
66+
const branch = required(props, "branch");
67+
const base = typeof props.base === "string" && props.base !== "" ? props.base : undefined;
68+
69+
const repository = yield* RepositoryContext.operations.current;
70+
if (repository === undefined) {
71+
throw new WorktreeCompositionError(
72+
name,
73+
"no-repository-context",
74+
`<Worktree name=${JSON.stringify(name)}> is invalid outside a lexical <Repository>: a ` +
75+
"Worktree is a checkout of one, and there is none in scope here.",
76+
);
77+
}
78+
79+
const record = yield* RepositoryComposition.operations.createWorktree({
80+
repositoryName: repository.name,
81+
name,
82+
branch,
83+
base,
84+
});
85+
yield* RepositoryComposition.operations.attachWorktree(record);
86+
87+
if (!(yield* hasContent())) {
88+
return record.checkoutPath;
89+
}
90+
91+
yield* API.Env.around(
92+
{
93+
// deno-lint-ignore require-yield
94+
*cwd(): Operation<string> {
95+
return record.checkoutPath;
96+
},
97+
},
98+
{ at: "min" },
99+
);
100+
return yield* content();
101+
}

‎packages/workflow/src/composition/installation.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Registers the composition components as ordinary defaults.
33
*
4-
* Repository and Dir are ordinary registered defaults — not
4+
* Repository, Worktree and Dir are ordinary registered defaults — not
55
* reserved and not structural — so a repository-local component may shadow
66
* one for its own scope, and the workflow host installs them only for a live
77
* or partial attachment. A completed root replay does not attach any
@@ -12,6 +12,7 @@
1212
import type { Operation } from "effection";
1313
import { registerComponents } from "@executablemd/core";
1414
import Repository, { props as repositoryProps } from "./components/Repository.ts";
15+
import Worktree, { props as worktreeProps } from "./components/Worktree.ts";
1516
import Dir, { props as dirProps } from "./components/Dir.ts";
1617

1718
const ORIGIN = "@executablemd/workflow/composition";
@@ -24,6 +25,12 @@ export function useCompositionComponents(): Operation<void> {
2425
props: repositoryProps,
2526
fn: Repository,
2627
},
28+
{
29+
name: "Worktree",
30+
origin: ORIGIN,
31+
props: worktreeProps,
32+
fn: Worktree,
33+
},
2734
{
2835
name: "Dir",
2936
origin: ORIGIN,

‎packages/workflow/src/deno/composition/effects.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,16 @@ import {
3030
refusalReason,
3131
repositoryRefusal,
3232
repositoryRefused,
33+
worktreeRefusal,
34+
worktreeRefused,
3335
} from "./refusals.ts";
3436

3537
/** The effect type a Repository's creation identity is recorded under. */
3638
export const WORKSPACE_REPOSITORY = "workspace_repository";
3739

40+
/** The effect type a Worktree's creation identity is recorded under. */
41+
export const WORKSPACE_WORKTREE = "workspace_worktree";
42+
3843
/**
3944
* What one composition effect recorded when it recorded a checkout.
4045
*
@@ -110,6 +115,7 @@ export function parentOf(path: string): string {
110115
* document asked for.
111116
*/
112117
export function* attempted(
118+
kind: "repository" | "worktree",
113119
name: string,
114120
subject: string,
115121
work: Operation<Json>,
@@ -118,7 +124,9 @@ export function* attempted(
118124
return created(yield* savepoint(work));
119125
} catch (error) {
120126
if (error instanceof GitRefusal) {
121-
repositoryRefused(name, error.reason);
127+
return kind === "repository"
128+
? repositoryRefused(name, error.reason)
129+
: worktreeRefused(name, error.reason);
122130
}
123131
if (isJournalableWorkspaceFailure(error)) {
124132
throw new RepositoryRetentionError(subject, error);
@@ -149,6 +157,7 @@ function* compositionEffect(
149157
* stays cancellation.
150158
*/
151159
export function* settled(
160+
kind: "repository" | "worktree",
152161
name: string,
153162
database: WorkflowRunDatabase,
154163
description: EffectDescription,
@@ -161,11 +170,11 @@ export function* settled(
161170
try {
162171
value = yield* compositionEffect(database, description, perform);
163172
} catch (error) {
164-
const reason = refusalReason(error);
173+
const reason = refusalReason(error, kind);
165174
if (reason === undefined) {
166175
throw error;
167176
}
168-
throw repositoryRefusal(name, reason);
177+
throw kind === "repository" ? repositoryRefusal(name, reason) : worktreeRefusal(name, reason);
169178
}
170179
const outcome = parseOutcome(value);
171180
if (outcome === undefined) {

‎packages/workflow/src/deno/composition/identity.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ export function agreedRepository(record: RepositoryRecord, subject: string): Rep
7575
return record;
7676
}
7777

78+
export function agreedWorktree(record: WorktreeRecord, subject: string): WorktreeRecord {
79+
if (record.checkoutPath !== worktreeCheckoutPath(record.repositoryName, record.name)) {
80+
throw stale(subject, "placement");
81+
}
82+
return record;
83+
}
84+
7885
/**
7986
* The locator retained for a Repository, once it is still the one it names.
8087
*
@@ -107,6 +114,10 @@ export function repositorySubject(name: string): string {
107114
return `repository ${JSON.stringify(name)}`;
108115
}
109116

117+
export function worktreeSubject(name: string): string {
118+
return `worktree ${JSON.stringify(name)}`;
119+
}
120+
110121
/**
111122
* A materialized checkout, and what it has to turn out to be.
112123
*

‎packages/workflow/src/deno/composition/provider.ts‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/**
22
* The Deno-local Repository composition provider.
33
*
4-
* This is the wiring: it installs one Api whose operations belong to the
5-
* component's own module. `repository.ts` owns what a `<Repository>` creates
6-
* and attaches;
4+
* This is the wiring: it installs one Api whose four operations belong to the
5+
* two components' own modules. `repository.ts` and `worktree.ts` own what a
6+
* `<Repository>` and a `<Worktree>` respectively create and attach;
77
* `effects.ts` owns the durable envelope both perform inside; `refusals.ts`
88
* owns the words a refusal travels in; `identity.ts` owns holding a retained
99
* record to the identity that names it.
@@ -26,21 +26,22 @@
2626

2727
import { type Operation } from "effection";
2828
import { RepositoryComposition } from "../../composition/api.ts";
29-
import type { RepositoryRecord } from "../../composition/records.ts";
29+
import type { RepositoryRecord, WorktreeRecord } from "../../composition/records.ts";
3030
import type { WorkflowRunDatabase } from "../../storage/api.ts";
3131
import { transactWorkspaceRoots } from "../workspace/private.ts";
3232
import type { PrivateWorkspaceTransaction } from "../workspace/private.ts";
3333
import { gitSession, type GitSession } from "./git.ts";
3434
import { denoRepositoryHost, type RepositoryHost } from "./host.ts";
35-
import { WORKSPACE_REPOSITORY } from "./effects.ts";
35+
import { WORKSPACE_REPOSITORY, WORKSPACE_WORKTREE } from "./effects.ts";
3636
import { stale, type Attached, type StaleReason } from "./identity.ts";
3737
import {
3838
createRepository,
3939
prepareRepositoryAttachment,
4040
repositoryDisagreement,
4141
} from "./repository.ts";
42+
import { createWorktree, prepareWorktreeAttachment, worktreeDisagreement } from "./worktree.ts";
4243

43-
export { WORKSPACE_REPOSITORY } from "./effects.ts";
44+
export { WORKSPACE_REPOSITORY, WORKSPACE_WORKTREE } from "./effects.ts";
4445

4546
/**
4647
* What a provider may observe about itself, for suites that count.
@@ -124,6 +125,28 @@ export function useRepositoryComposition(
124125
repositoryDisagreement(git, attached, record.objectFormat, record.creationCommit),
125126
);
126127
},
128+
129+
*createWorktree([request]): Operation<WorktreeRecord> {
130+
observe.effect?.("worktree", request.name);
131+
return yield* createWorktree(database, host, request);
132+
},
133+
134+
*attachWorktree([record]): Operation<void> {
135+
observe.attachment?.("worktree", record.name);
136+
yield* attach(
137+
database,
138+
host,
139+
`worktree ${JSON.stringify(record.name)}`,
140+
(workspace, root) =>
141+
prepareWorktreeAttachment(
142+
workspace,
143+
root,
144+
record,
145+
`worktree ${JSON.stringify(record.name)}`,
146+
),
147+
(git, attached) => worktreeDisagreement(git, attached, record),
148+
);
149+
},
127150
},
128151
{ at: "min" },
129152
);

0 commit comments

Comments
 (0)