Skip to content

Commit 8587ca6

Browse files
committed
Merge remote-tracking branch 'origin/main' into APL-1041
2 parents bf517cc + 62c684a commit 8587ca6

File tree

9 files changed

+232
-17
lines changed

9 files changed

+232
-17
lines changed

docs/gitops.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,125 @@ sequenceDiagram
212212
API-->>Client: HTTP 409
213213
deactivate API
214214
```
215+
216+
## Version three - Git Worktrees
217+
218+
The git worktrees implementation eliminates the performance bottleneck of cloning from remote repository for each request. Instead, a main repository is maintained locally and git worktrees are created for each session. This provides instant session creation while maintaining complete isolation between concurrent operations.
219+
220+
**Key improvements:**
221+
- Main repository cloned once at startup, eliminating remote clone overhead
222+
- Git worktrees created instantly from local main repository
223+
- Each session operates on an isolated branch with independent `.git/index.lock` files
224+
- Direct push from session branch to main branch on remote
225+
- Automatic cleanup of worktrees and branches after completion
226+
- No blocking operations between concurrent sessions due to isolated git indexes
227+
228+
```mermaid
229+
sequenceDiagram
230+
autonumber
231+
participant Client1 as Client 1
232+
participant Client2 as Client 2
233+
participant API as API Server
234+
participant MainRepo as Main Git Repo
235+
participant Worktree1 as Session Worktree 1
236+
participant Worktree2 as Session Worktree 2
237+
participant Remote as Remote Git Repository
238+
239+
240+
Note over API,MainRepo: Application Startup
241+
API->>MainRepo: Initialize main repo (clone/pull)
242+
activate MainRepo
243+
MainRepo-->>API: Ready
244+
245+
246+
Note over Client1,Remote: Concurrent API Requests
247+
Client1->>API: POST/PUT/DELETE Request
248+
activate API
249+
API->>API: Generate sessionId (UUID)
250+
API->>MainRepo: createWorktree(sessionId, main)
251+
MainRepo->>Worktree1: git worktree add -b sessionId path main
252+
activate Worktree1
253+
Worktree1-->>MainRepo: Session branch created
254+
MainRepo-->>API: Worktree repo instance
255+
API-->>Client1: Processing...
256+
257+
258+
Client2->>API: POST/PUT/DELETE Request (concurrent)
259+
API->>API: Generate sessionId (UUID)
260+
API->>MainRepo: createWorktree(sessionId2, main)
261+
MainRepo->>Worktree2: git worktree add -b sessionId2 path main
262+
activate Worktree2
263+
Worktree2-->>MainRepo: Session branch created
264+
MainRepo-->>API: Worktree repo instance
265+
API-->>Client2: Processing...
266+
267+
268+
Note over Worktree1,Remote: Session 1 Operations
269+
API->>Worktree1: writeFile(), modify configs
270+
API->>Worktree1: commit("otomi-api commit by user1")
271+
Worktree1->>Worktree1: git add . && git commit
272+
API->>Worktree1: save() - pull/push with retry
273+
loop Retry on conflicts
274+
Worktree1->>Remote: git pull origin main --rebase
275+
Remote-->>Worktree1: Latest changes
276+
Worktree1->>Remote: git push origin sessionId:main
277+
alt Push successful
278+
Remote-->>Worktree1: Success
279+
else Push failed (conflict)
280+
Remote-->>Worktree1: Conflict - retry
281+
end
282+
end
283+
284+
285+
Note over Worktree2,Remote: Session 2 Operations (parallel)
286+
API->>Worktree2: writeFile(), modify configs
287+
API->>Worktree2: commit("otomi-api commit by user2")
288+
Worktree2->>Worktree2: git add . && git commit
289+
API->>Worktree2: save() - pull/push with retry
290+
loop Retry on conflicts
291+
Worktree2->>Remote: git pull origin main --rebase
292+
Remote-->>Worktree2: Latest changes + Session1 commits
293+
Worktree2->>Remote: git push origin sessionId2:main
294+
alt Push successful
295+
Remote-->>Worktree2: Success
296+
else Push failed (conflict)
297+
Remote-->>Worktree2: Conflict - retry
298+
end
299+
end
300+
301+
302+
Note over API,Remote: Cleanup
303+
API->>MainRepo: removeWorktree(sessionId)
304+
MainRepo->>Worktree1: git worktree remove path
305+
deactivate Worktree1
306+
MainRepo-->>API: Worktree removed (branch auto-deleted)
307+
API-->>Client1: Response
308+
309+
310+
API->>MainRepo: removeWorktree(sessionId2)
311+
MainRepo->>Worktree2: git worktree remove path2
312+
deactivate Worktree2
313+
MainRepo-->>API: Worktree removed (branch auto-deleted)
314+
API-->>Client2: Response
315+
316+
317+
deactivate API
318+
deactivate MainRepo
319+
320+
321+
Note over Client1,Remote: Result: No slow remote cloning per request
322+
Note over Client1,Remote: Result: Instant worktree creation from local repo
323+
Note over Client1,Remote: Each session worked on isolated branch
324+
Note over Client1,Remote: Direct push to main: sessionBranch:main
325+
Note over Client1,Remote: No index.lock conflicts between sessions
326+
```
327+
328+
**Git Index Isolation:**
329+
Version two avoided `.git/index.lock` conflicts by cloning the entire repository from remote to separate directories for each session. While this provided isolation, it created a significant performance bottleneck due to repeated remote cloning.
330+
331+
Git worktrees provide the same isolation benefits but with instant creation from a local repository:
332+
- Main repo: `.git/index` and `.git/index.lock`
333+
- Worktree 1: `.git/worktrees/sessionId1/index` and `.git/worktrees/sessionId1/index.lock`
334+
- Worktree 2: `.git/worktrees/sessionId2/index` and `.git/worktrees/sessionId2/index.lock`
335+
336+
This maintains the concurrency benefits of version two while eliminating the remote cloning performance penalty.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@
141141
"cz": "git-cz",
142142
"cz:retry": "git-cz --retry",
143143
"dev": "run-p watch dev:node",
144-
"dev:node": "export $(grep -v '^#' .env | xargs) && tsx watch --inspect=4321 src/app.ts",
144+
"dev:node": "tsx watch --env-file=.env --inspect=4321 src/app.ts",
145145
"lint": "run-p types lint:ts",
146146
"lint:ts": "eslint --ext ts .",
147147
"lint:fix": "eslint --ext ts --fix .",

src/api.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable @typescript-eslint/no-empty-function */
21
import * as getValuesSchemaModule from './utils'
32
import OpenAPISchemaValidator from 'openapi-schema-validator'
43
import { getSpec } from 'src/app'

src/authz.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable @typescript-eslint/ban-ts-comment */
21
import { Ability, Subject, subject } from '@casl/ability'
32
import Debug from 'debug'
43
import { each, forIn, get, isEmpty, isEqual, omit, set } from 'lodash'

src/git.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,11 +419,56 @@ export class Git {
419419
async push(): Promise<any> {
420420
if (!this.url && this.isRootClone()) return
421421
debug('Pushing')
422-
const summary = await this.git.push(this.remote, this.branch)
423-
debug('Pushed. Summary: ', summary)
422+
423+
// For worktrees, push current branch (session branch) to main branch
424+
// For main repo, push normally
425+
if (!this.isRootClone()) {
426+
const currentBranch = await this.git.revparse(['--abbrev-ref', 'HEAD'])
427+
const summary = await this.git.push([this.remote, `${currentBranch}:${this.branch}`])
428+
debug('Pushed session branch to main. Summary: ', summary)
429+
} else {
430+
// Original push logic for main repo
431+
const summary = await this.git.push(this.remote, this.branch)
432+
debug('Pushed. Summary: ', summary)
433+
}
424434
return
425435
}
426436

437+
async createWorktree(worktreePath: string, branch: string = this.branch): Promise<void> {
438+
debug(`Creating worktree at: ${worktreePath} from branch: ${branch}`)
439+
await ensureDir(dirname(worktreePath), { mode: 0o744 })
440+
441+
// Use sessionId as branch name (from worktree path)
442+
const sessionId = basename(worktreePath)
443+
const sessionBranch = sessionId
444+
445+
// Create worktree with session branch
446+
await this.git.raw(['worktree', 'add', '-b', sessionBranch, worktreePath, branch])
447+
debug(`Worktree created successfully at: ${worktreePath} on branch: ${sessionBranch}`)
448+
}
449+
450+
async removeWorktree(worktreePath: string): Promise<void> {
451+
debug(`Removing worktree at: ${worktreePath}`)
452+
try {
453+
await this.git.raw(['worktree', 'remove', worktreePath])
454+
debug(`Worktree removed successfully: ${worktreePath}`)
455+
} catch (error) {
456+
const errorMessage = getSanitizedErrorMessage(error)
457+
debug(`Error removing worktree: ${errorMessage}`)
458+
try {
459+
await this.git.raw(['worktree', 'remove', '--force', worktreePath])
460+
debug(`Worktree force removed: ${worktreePath}`)
461+
} catch (err) {
462+
const errMessage = getSanitizedErrorMessage(err)
463+
debug(`Failed to force remove worktree: ${errMessage}`)
464+
if (await pathExists(worktreePath)) {
465+
rmSync(worktreePath, { recursive: true, force: true })
466+
debug(`Manually removed worktree directory: ${worktreePath}`)
467+
}
468+
}
469+
}
470+
}
471+
427472
async getCommitSha(): Promise<string> {
428473
return this.git.revparse('HEAD')
429474
}
@@ -479,6 +524,23 @@ export class Git {
479524
}
480525
}
481526

527+
export async function getWorktreeRepo(
528+
mainRepo: Git,
529+
worktreePath: string,
530+
branch: string = mainRepo.branch,
531+
): Promise<Git> {
532+
debug(`Creating worktree repo at: ${worktreePath}`)
533+
534+
await mainRepo.createWorktree(worktreePath, branch)
535+
536+
const worktreeRepo = new Git(worktreePath, mainRepo.url, mainRepo.user, mainRepo.email, mainRepo.urlAuth, branch)
537+
538+
await worktreeRepo.addConfig()
539+
await worktreeRepo.initSops()
540+
541+
return worktreeRepo
542+
}
543+
482544
export default async function getRepo(
483545
path: string,
484546
url: string,

src/middleware/error.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable no-param-reassign */
21
import { debug, error } from 'console'
32
import { Response } from 'express'
43
import { HttpError, OtomiError } from 'src/error'
@@ -9,7 +8,7 @@ import { cleanSession } from './session'
98
const env = cleanEnv({})
109

1110
// Note: 4 arguments (no more, no less) must be defined in your errorMiddleware function. Otherwise the function will be silently ignored.
12-
// eslint-disable-next-line no-unused-vars
11+
1312
export function errorMiddleware(e, req: OpenApiRequest, res: Response, next): void {
1413
if (env.isDev) error('errorMiddleware error', e)
1514
else debug('errorMiddleware error', e)

src/middleware/session.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { OpenApiRequestExt } from 'src/otomi-models'
1111
import { default as OtomiStack, rootPath } from 'src/otomi-stack'
1212
import { cleanEnv, EDITOR_INACTIVITY_TIMEOUT } from 'src/validators'
1313
import { v4 as uuidv4 } from 'uuid'
14+
import { getSanitizedErrorMessage } from '../utils'
1415

1516
const debug = Debug('otomi:session')
1617
const env = cleanEnv({
@@ -41,8 +42,7 @@ export const setSessionStack = async (editor: string, sessionId: string): Promis
4142
if (!sessions[sessionId]) {
4243
debug(`Creating session ${sessionId} for user ${editor}`)
4344
sessions[sessionId] = new OtomiStack(editor, sessionId)
44-
// init repo without inflating values from files as its faster to copy the values
45-
await sessions[sessionId].initGit(false)
45+
await sessions[sessionId].initGitWorktree(readOnlyStack.git)
4646
sessions[sessionId].repoService = cloneDeep(readOnlyStack.repoService)
4747
} else sessions[sessionId].sessionId = sessionId
4848
return sessions[sessionId]
@@ -59,8 +59,20 @@ export const cleanAllSessions = (): void => {
5959

6060
export const cleanSession = async (sessionId: string): Promise<void> => {
6161
debug(`Cleaning session ${sessionId}`)
62+
const session = sessions[sessionId]
63+
const worktreePath = join(rootPath, sessionId)
64+
if (session?.git) {
65+
try {
66+
await readOnlyStack.git.removeWorktree(worktreePath)
67+
} catch (error) {
68+
const errorMessage = getSanitizedErrorMessage(error)
69+
debug(`Error removing worktree for session ${sessionId}: ${errorMessage}`)
70+
await remove(worktreePath)
71+
}
72+
} else {
73+
await remove(worktreePath)
74+
}
6275
delete sessions[sessionId]
63-
await remove(join(rootPath, sessionId))
6476
}
6577

6678
let io: Server
@@ -98,6 +110,7 @@ export function sessionMiddleware(server: http.Server): RequestHandler {
98110
if (['post', 'put', 'delete'].includes(req.method.toLowerCase())) {
99111
// in the workloadCatalog endpoint(s), don't need to create a session
100112
if (req.path === '/v1/workloadCatalog' || req.path === '/v1/createWorkloadCatalog') return next()
113+
101114
// bootstrap session stack with unique sessionId to manipulate data
102115
const sessionId = uuidv4() as string
103116
// eslint-disable-next-line no-param-reassign

src/otomi-stack.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CoreV1Api, User as k8sUser, KubeConfig, V1ObjectReference } from '@kubernetes/client-node'
1+
import { CoreV1Api, KubeConfig, User as k8sUser, V1ObjectReference } from '@kubernetes/client-node'
22
import Debug from 'debug'
33

44
import { getRegions, ObjectStorageKeyRegions } from '@linode/api-v4'
@@ -9,7 +9,7 @@ import { generate as generatePassword } from 'generate-password'
99
import { cloneDeep, filter, isEmpty, map, mapValues, merge, omit, pick, set, unset } from 'lodash'
1010
import { getAppList, getAppSchema, getSpec } from 'src/app'
1111
import { AlreadyExists, ForbiddenError, HttpError, OtomiError, PublicUrlExists, ValidationError } from 'src/error'
12-
import getRepo, { Git } from 'src/git'
12+
import getRepo, { getWorktreeRepo, Git } from 'src/git'
1313
import { cleanSession, getSessionStack } from 'src/middleware'
1414
import {
1515
AplBackupRequest,
@@ -329,6 +329,25 @@ export default class OtomiStack {
329329
debug(`Values are loaded for ${this.editor} in ${this.sessionId}`)
330330
}
331331

332+
async initGitWorktree(mainRepo: Git): Promise<void> {
333+
await this.init()
334+
debug(`Creating worktree for session ${this.sessionId}`)
335+
336+
try {
337+
await mainRepo.git.revparse(`--verify refs/heads/${env.GIT_BRANCH}`)
338+
} catch (error) {
339+
const errorMessage = getSanitizedErrorMessage(error)
340+
throw new Error(
341+
`Main repository does not have branch '${env.GIT_BRANCH}'. Cannot create worktree. ${errorMessage}`,
342+
)
343+
}
344+
345+
const worktreePath = this.getRepoPath()
346+
this.git = await getWorktreeRepo(mainRepo, worktreePath, env.GIT_BRANCH)
347+
348+
debug(`Worktree created for ${this.editor} in ${this.sessionId}`)
349+
}
350+
332351
getSecretPaths(): string[] {
333352
// we split secrets from plain data, but have to overcome teams using patternproperties
334353
const teamProp = 'teamConfig.patternProperties.^[a-z0-9]([-a-z0-9]*[a-z0-9])+$'
@@ -1950,8 +1969,10 @@ export default class OtomiStack {
19501969
try {
19511970
// Commit and push Git changes
19521971
await this.git.save(this.editor!, encryptSecrets, files)
1972+
// Pull the latest changes to ensure we have the most recent state
1973+
await rootStack.git.git.pull()
19531974

1954-
// Execute the provided action dynamically
1975+
// Update the team configuration of the root stack
19551976
action(rootStack.repoService.getTeamConfigService(teamId))
19561977

19571978
debug(`Updated root stack values with ${this.sessionId} changes`)
@@ -1974,8 +1995,9 @@ export default class OtomiStack {
19741995
try {
19751996
// Commit and push Git changes
19761997
await this.git.save(this.editor!, encryptSecrets, files)
1977-
1978-
// Execute the provided action dynamically
1998+
// Pull the latest changes to ensure we have the most recent state
1999+
await rootStack.git.git.pull()
2000+
// update the repo configuration of the root stack
19792001
action(rootStack.repoService)
19802002

19812003
debug(`Updated root stack values with ${this.sessionId} changes`)

src/utils/workloadUtils.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,6 @@ describe('fetchChartYaml', () => {
183183

184184
const result = await fetchChartYaml(url)
185185

186-
// eslint-disable-next-line @typescript-eslint/unbound-method
187186
expect(axios.get).toHaveBeenCalledWith(
188187
'https://raw.githubusercontent.com/owner/repo/main/charts/mychart/Chart.yaml',
189188
{ responseType: 'text' },
@@ -197,7 +196,7 @@ describe('fetchChartYaml', () => {
197196
const result = await fetchChartYaml(url)
198197

199198
expect(result).toEqual({ values: {}, error: 'Unsupported Git provider or invalid URL format.' })
200-
// eslint-disable-next-line @typescript-eslint/unbound-method
199+
201200
expect(axios.get).not.toHaveBeenCalled()
202201
})
203202

0 commit comments

Comments
 (0)