Skip to content

Commit 50bfd3c

Browse files
committed
feat: enhance pull functionality with force options and local change detection
- Added new commands `pull:dev:force` and `pull:prod:force` to allow users to overwrite local changes with platform state. - Updated the README to reflect the new pull commands and their behavior, including descriptions for preserving or overwriting local changes. - Implemented logic in the pull process to detect locally changed files and skip them during pulls unless forced, improving user control over resource management.
1 parent 4a09e7a commit 50bfd3c

3 files changed

Lines changed: 126 additions & 103 deletions

File tree

README.md

Lines changed: 39 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@ echo "VAPI_TOKEN=your-token-here" > .env.dev
6262
| Command | Description |
6363
|---------|-------------|
6464
| `npm run build` | Type-check the codebase |
65-
| `npm run pull:dev` | Pull platform state, merge with local changes |
66-
| `npm run pull:prod` | Pull from prod, merge with local changes |
65+
| `npm run pull:dev` | Pull platform state, preserve local changes |
66+
| `npm run pull:dev:force` | Pull platform state, overwrite everything |
67+
| `npm run pull:prod` | Pull from prod, preserve local changes |
68+
| `npm run pull:prod:force` | Pull from prod, overwrite everything |
6769
| `npm run push:dev` | Push local files to Vapi (dev) |
6870
| `npm run push:prod` | Push local files to Vapi (prod) |
6971
| `npm run apply:dev` | Pull → Merge → Push in one shot (dev) |
@@ -76,50 +78,51 @@ echo "VAPI_TOKEN=your-token-here" > .env.dev
7678
### Basic Workflow
7779

7880
```bash
79-
# First time: pull existing resources from Vapi
80-
npm run pull:dev
81+
# First time: pull all resources from Vapi
82+
npm run pull:dev:force
8183

8284
# Commit the initial state
8385
git add . && git commit -m "initial pull"
8486

8587
# Make changes to YAML/MD files...
8688

87-
# Safe sync: pull latest platform changes, merge, push
88-
npm run apply:dev
89+
# Push changes to Vapi
90+
npm run push:dev
8991
```
9092

91-
#### Pull → Edit → Push (manual two-step)
93+
#### Pulling Without Losing Local Work
9294

93-
If you want more control over each step:
95+
By default, `pull` preserves any files you've locally modified or deleted:
9496

9597
```bash
96-
# Pull platform state (auto-merges with your local changes via git stash/pop)
97-
npm run pull:dev
98+
# Edit an assistant locally...
9899

99-
# Review the merged result, make further edits...
100-
101-
# Push to platform
102-
npm run push:dev
100+
npm run pull:dev
101+
# ⏭️ my-assistant (locally changed, skipping)
102+
# ✨ new-tool -> resources/tools/new-tool.yml
103+
# Your edits are preserved, new platform resources are downloaded
103104
```
104105

105-
#### Handling Merge Conflicts
106+
#### Force Pull (Platform as Source of Truth)
106107

107-
If platform changes conflict with your local edits, `pull` will leave standard git conflict markers in the affected files and exit:
108+
When you want the platform version of everything, overwriting all local files:
108109

109110
```bash
110-
npm run pull:dev
111-
# ⚠️ Merge conflicts detected!
111+
npm run pull:dev:force
112+
# ⚡ Force mode: overwriting all local files with platform state
113+
```
112114

113-
# See which files have conflicts
114-
git diff --name-only --diff-filter=U
115+
#### Reviewing Platform Changes
115116

116-
# Edit files to resolve conflicts (remove <<<< ==== >>>> markers)
117+
```bash
118+
# Pull platform state (your local changes are preserved)
119+
npm run pull:dev
117120

118-
# Push resolved state
119-
npm run push:dev
121+
# See what changed on the platform vs your last commit
122+
git diff
120123

121-
# Clean up the stash
122-
git stash drop
124+
# Accept platform changes for a specific file
125+
git checkout -- resources/tools/some-tool.yml
123126
```
124127

125128
### Selective Push (Partial Sync)
@@ -471,30 +474,24 @@ model:
471474

472475
### Sync Workflow
473476

474-
The engine uses git's merge capabilities to safely combine local and platform changes:
477+
Your local files are the source of truth. The engine respects that:
475478

476479
```
477-
┌─────────┐ ┌──────────┐ ┌──────────┐
478-
│ pull │ ──▸ │ merge │ ──▸ │ push
479-
│ platform │ │ (git │ │ to │
480-
│ state │ │ stash/ │ │ platform │
481-
│ pop) │ │ │
482-
└─────────┘ └──────────┘ └──────────┘
480+
pull (default) pull --force push
481+
───────────── ───────────── ─────────────
482+
Download from Download from Upload local
483+
platform, skip platform, overwrite files to
484+
locally changed everything platform
485+
files
483486
```
484487

485-
**`pull`** does the heavy lifting:
486-
1. Detects local uncommitted changes → `git stash`
487-
2. Downloads fresh platform state (overwrites resource files)
488-
3. Reapplies local changes on top → `git stash pop`
489-
4. Git's three-way merge reconciles both sets of changes
490-
5. If conflicts: leaves standard `<<<<<<<` markers, exits for manual resolution
491-
6. If clean: working tree has merged files ready to push
488+
**`pull`** downloads platform state. In default mode (git repo required), it detects locally modified or deleted files and skips them — your local work is preserved. New platform resources are still downloaded. Use `--force` to overwrite everything.
492489

493-
**`push`** is the engine — reads local files and syncs them to the platform.
490+
**`push`** is the engine — reads local files and syncs them to the platform. Deleted files are removed from the platform.
494491

495-
**`apply`** is the convenience wrapper — runs `pull` then `push` in sequence. Stops if pull has conflicts.
492+
**`apply`** is the convenience wrapper — runs `pull` then `push` in sequence.
496493

497-
> **Note:** `pull` requires a git repo with at least one commit. Without git, it falls back to a simple overwrite (no merge support).
494+
> **Note:** The "skip locally changed files" feature requires a git repo with at least one commit. Without git, pull always overwrites (same as `--force`).
498495

499496
### Processing Order
500497

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
"push:dev:force": "tsx src/push.ts dev --force",
1313
"push:prod:force": "tsx src/push.ts prod --force",
1414
"pull:dev": "tsx src/pull.ts dev",
15+
"pull:dev:force": "tsx src/pull.ts dev --force",
1516
"pull:prod": "tsx src/pull.ts prod",
17+
"pull:prod:force": "tsx src/pull.ts prod --force",
1618
"call:dev": "tsx src/call.ts dev",
1719
"call:prod": "tsx src/call.ts prod",
1820
"cleanup:dev": "tsx src/cleanup.ts dev",

src/pull.ts

Lines changed: 85 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const FOLDER_MAP: Record<ResourceType, string> = {
5454
};
5555

5656
// ─────────────────────────────────────────────────────────────────────────────
57-
// Git Helpers (merge support — stash local changes before pull, reapply after)
57+
// Git Helpers (detect locally changed files to skip during pull)
5858
// ─────────────────────────────────────────────────────────────────────────────
5959

6060
function gitCmd(args: string): string {
@@ -73,8 +73,22 @@ function gitHasCommits(): boolean {
7373
try { gitCmd("rev-parse HEAD"); return true; } catch { return false; }
7474
}
7575

76-
function gitHasChanges(): boolean {
77-
return gitCmd("status --porcelain").length > 0;
76+
// Returns relative paths of all locally modified, deleted, or untracked files
77+
function getLocallyChangedFiles(): Set<string> {
78+
const status = gitCmd("status --porcelain");
79+
const files = new Set<string>();
80+
for (const line of status.split("\n")) {
81+
if (!line.trim()) continue;
82+
// format: XY filename (or XY "filename" for special chars)
83+
let filePath = line.slice(3);
84+
// Handle renames: "old -> new"
85+
const arrowIdx = filePath.indexOf(" -> ");
86+
if (arrowIdx !== -1) filePath = filePath.slice(arrowIdx + 4);
87+
// Strip quotes if present
88+
filePath = filePath.replace(/^"|"$/g, "").trim();
89+
files.add(filePath);
90+
}
91+
return files;
7892
}
7993

8094
// ─────────────────────────────────────────────────────────────────────────────
@@ -373,19 +387,21 @@ async function writeResourceFile(
373387
export interface PullStats {
374388
created: number;
375389
updated: number;
390+
skipped: number;
376391
}
377392

378393
export async function pullResourceType(
379394
resourceType: ResourceType,
380-
state: StateFile
395+
state: StateFile,
396+
changedFiles?: Set<string>,
381397
): Promise<PullStats> {
382398
console.log(`\n📥 Pulling ${resourceType}...`);
383399

384400
const resources = await fetchAllResources(resourceType) ?? [];
385401

386402
if (!Array.isArray(resources)) {
387403
console.log(` ⚠️ No ${resourceType} found (API returned non-array)`);
388-
return { created: 0, updated: 0 };
404+
return { created: 0, updated: 0, skipped: 0 };
389405
}
390406

391407
console.log(` Found ${resources.length} ${resourceType} in Vapi`);
@@ -396,6 +412,7 @@ export async function pullResourceType(
396412

397413
let created = 0;
398414
let updated = 0;
415+
let skipped = 0;
399416

400417
for (const resource of resources) {
401418
// Check if we already have this resource in state (by UUID)
@@ -406,9 +423,19 @@ export async function pullResourceType(
406423
// Generate new resource ID
407424
resourceId = generateResourceId(resource, existingIds);
408425
existingIds.add(resourceId);
409-
created++;
410-
} else {
411-
updated++;
426+
}
427+
428+
// Skip files that have been locally modified or deleted (default mode)
429+
if (changedFiles) {
430+
const folderPath = FOLDER_MAP[resourceType];
431+
const mdPath = join("resources", folderPath, `${resourceId}.md`);
432+
const ymlPath = join("resources", folderPath, `${resourceId}.yml`);
433+
if (changedFiles.has(mdPath) || changedFiles.has(ymlPath)) {
434+
console.log(` ⏭️ ${resourceId} (locally changed, skipping)`);
435+
newStateSection[resourceId] = resource.id;
436+
skipped++;
437+
continue;
438+
}
412439
}
413440

414441
// Detect platform defaults (orgId is null/missing — read-only, immutable)
@@ -429,95 +456,92 @@ export async function pullResourceType(
429456
const relPath = relative(BASE_DIR, filePath);
430457
console.log(` ${icon} ${resourceId} -> ${relPath}${isPlatformDefault ? " (platform default, read-only)" : ""}`);
431458

459+
if (isNew) created++;
460+
else updated++;
461+
432462
// Update state
433463
newStateSection[resourceId] = resource.id;
434464
}
435465

436466
// Update state with new mappings
437467
state[resourceType] = newStateSection;
438468

439-
return { created, updated };
469+
return { created, updated, skipped };
440470
}
441471

442472
// ─────────────────────────────────────────────────────────────────────────────
443473
// Main Pull Engine
444474
// ─────────────────────────────────────────────────────────────────────────────
445475

446476
async function main(): Promise<void> {
477+
const force = process.argv.includes("--force");
478+
447479
console.log("═══════════════════════════════════════════════════════════════");
448-
console.log(`🔄 Vapi GitOps Pull - Environment: ${VAPI_ENV}`);
480+
console.log(`🔄 Vapi GitOps Pull - Environment: ${VAPI_ENV}${force ? " (force)" : ""}`);
449481
console.log(` API: ${VAPI_BASE_URL}`);
450482
console.log("═══════════════════════════════════════════════════════════════");
451483

452-
// Git merge support: stash local changes before overwriting with platform state
453-
const gitEnabled = isGitRepo() && gitHasCommits();
454-
const hadLocalChanges = gitEnabled && gitHasChanges();
484+
// Default mode: skip locally changed files (local is source of truth)
485+
// Force mode: overwrite everything (platform is source of truth)
486+
let changedFiles: Set<string> | undefined;
487+
const gitEnabled = !force && isGitRepo() && gitHasCommits();
455488

456-
if (hadLocalChanges) {
457-
console.log("\n📦 Stashing local changes before pull...");
458-
gitCmd('stash push -m "gitops: stash before pull"');
459-
console.log(" ✅ Local changes stashed\n");
460-
} else if (gitEnabled) {
461-
console.log("\n📦 No local changes to stash\n");
489+
if (gitEnabled) {
490+
changedFiles = getLocallyChangedFiles();
491+
// Only keep resource files — non-resource changes don't matter
492+
for (const f of changedFiles) {
493+
if (!f.startsWith("resources/")) changedFiles.delete(f);
494+
}
495+
if (changedFiles.size > 0) {
496+
console.log(`\n📦 ${changedFiles.size} locally changed file(s) will be preserved`);
497+
console.log(" Use --force to overwrite all local files with platform state");
498+
}
499+
} else if (force) {
500+
console.log("\n⚡ Force mode: overwriting all local files with platform state");
462501
}
463502

464503
const state = loadState();
465504

505+
const zero: PullStats = { created: 0, updated: 0, skipped: 0 };
466506
const stats: Record<string, PullStats> = {
467-
tools: { created: 0, updated: 0 },
468-
structuredOutputs: { created: 0, updated: 0 },
469-
assistants: { created: 0, updated: 0 },
470-
squads: { created: 0, updated: 0 },
471-
personalities: { created: 0, updated: 0 },
472-
scenarios: { created: 0, updated: 0 },
473-
simulations: { created: 0, updated: 0 },
474-
simulationSuites: { created: 0, updated: 0 },
507+
tools: { ...zero },
508+
structuredOutputs: { ...zero },
509+
assistants: { ...zero },
510+
squads: { ...zero },
511+
personalities: { ...zero },
512+
scenarios: { ...zero },
513+
simulations: { ...zero },
514+
simulationSuites: { ...zero },
475515
};
476516

477517
// Pull in dependency order
478-
stats.tools = await pullResourceType("tools", state);
479-
stats.structuredOutputs = await pullResourceType("structuredOutputs", state);
480-
stats.assistants = await pullResourceType("assistants", state);
481-
stats.squads = await pullResourceType("squads", state);
482-
stats.personalities = await pullResourceType("personalities", state);
483-
stats.scenarios = await pullResourceType("scenarios", state);
484-
stats.simulations = await pullResourceType("simulations", state);
485-
stats.simulationSuites = await pullResourceType("simulationSuites", state);
486-
487-
// Reapply local changes on top of pulled platform state
488-
let hasConflicts = false;
489-
if (hadLocalChanges) {
490-
console.log("\n📦 Reapplying local changes...");
491-
try {
492-
gitCmd("stash pop");
493-
console.log(" ✅ Local changes merged cleanly\n");
494-
} catch {
495-
hasConflicts = true;
496-
}
497-
}
518+
stats.tools = await pullResourceType("tools", state, changedFiles);
519+
stats.structuredOutputs = await pullResourceType("structuredOutputs", state, changedFiles);
520+
stats.assistants = await pullResourceType("assistants", state, changedFiles);
521+
stats.squads = await pullResourceType("squads", state, changedFiles);
522+
stats.personalities = await pullResourceType("personalities", state, changedFiles);
523+
stats.scenarios = await pullResourceType("scenarios", state, changedFiles);
524+
stats.simulations = await pullResourceType("simulations", state, changedFiles);
525+
stats.simulationSuites = await pullResourceType("simulationSuites", state, changedFiles);
498526

499-
// Always save state last — overwrites any stash-induced changes to the state file
500527
await saveState(state);
501528

502-
if (hasConflicts) {
503-
console.error("\n⚠️ Merge conflicts detected!");
504-
console.error(" Platform changes conflict with your local changes.\n");
505-
console.error(" To see conflicted files:");
506-
console.error(" git diff --name-only --diff-filter=U\n");
507-
console.error(" After resolving conflicts:");
508-
console.error(` npm run push:${VAPI_ENV} # push to platform`);
509-
console.error(" git stash drop # clean up the stash\n");
510-
process.exit(1);
511-
}
512-
513529
// Summary
530+
const totalSkipped = Object.values(stats).reduce((sum, s) => sum + s.skipped, 0);
514531
console.log("\n═══════════════════════════════════════════════════════════════");
515532
console.log("✅ Pull complete!");
516533
console.log("═══════════════════════════════════════════════════════════════\n");
517534

518535
console.log("📋 Summary:");
519-
for (const [type, { created, updated }] of Object.entries(stats)) {
520-
console.log(` ${type}: ${created} new, ${updated} existing`);
536+
for (const [type, { created, updated, skipped }] of Object.entries(stats)) {
537+
const parts = [`${created} new`, `${updated} updated`];
538+
if (skipped > 0) parts.push(`${skipped} skipped`);
539+
console.log(` ${type}: ${parts.join(", ")}`);
540+
}
541+
542+
if (totalSkipped > 0) {
543+
console.log(`\n ℹ️ ${totalSkipped} file(s) preserved (locally changed)`);
544+
console.log(" Run with --force to overwrite: npm run pull:dev:force");
521545
}
522546
}
523547

0 commit comments

Comments
 (0)