Skip to content

Commit 863715b

Browse files
committed
Watch modifications of externally included files
1 parent 5855a51 commit 863715b

3 files changed

Lines changed: 167 additions & 1 deletion

File tree

packages/databricks-vscode/src/bundle/BundleFileSet.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,43 @@ describe(__filename, async function () {
139139
)
140140
).to.be.false;
141141
});
142+
143+
it("getExternalIncludeWatchTargets should only return bases outside the project root", async () => {
144+
const sharedDir = path.join(tmpdir.path, "shared");
145+
const projectDir = path.join(tmpdir.path, "project", "sub");
146+
await fs.mkdir(sharedDir, {recursive: true});
147+
await fs.mkdir(projectDir, {recursive: true});
148+
149+
const rootBundleData: BundleSchema = {
150+
include: [
151+
"../../shared/config.yml",
152+
"../../shared/*.yml",
153+
"local.yml",
154+
"includes/**/*.yml",
155+
],
156+
};
157+
await fs.writeFile(
158+
path.join(projectDir, "databricks.yml"),
159+
yaml.stringify(rootBundleData)
160+
);
161+
162+
const bundleFileSet = new BundleFileSet(
163+
getWorkspaceFolderManagerMock(projectDir)
164+
);
165+
166+
const targets =
167+
await bundleFileSet.getExternalIncludeWatchTargets();
168+
169+
// Only the two ../../shared patterns escape the project root; the
170+
// in-tree patterns (local.yml, includes/**) are covered by the
171+
// default recursive watcher and must be excluded.
172+
const summary = targets
173+
.map((t) => `${t.baseUri.fsPath}|${t.pattern}`)
174+
.sort();
175+
expect(summary).to.deep.equal(
176+
[`${sharedDir}|config.yml`, `${sharedDir}|*.yml`].sort()
177+
);
178+
});
142179
});
143180

144181
describe("file listing", async () => {

packages/databricks-vscode/src/bundle/BundleFileSet.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,35 @@ function toGlobPath(path: string) {
5555
return path;
5656
}
5757

58+
const globMagicChars = /[*?{}[\]!+@()]/;
59+
60+
/**
61+
* Splits an absolute glob path into its static base directory (the leading
62+
* segments that contain no glob magic characters) and the remaining glob
63+
* pattern. e.g. "/a/b/sub/**\/*.yml" -> {base: "/a/b/sub", pattern: "**\/*.yml"}.
64+
* A pattern without any magic characters is treated as a literal file: its
65+
* directory becomes the base and its filename the pattern.
66+
*/
67+
function splitGlobBase(absolutePath: string): {base: string; pattern: string} {
68+
const segments = absolutePath.split(path.sep);
69+
const staticSegments: string[] = [];
70+
let i = 0;
71+
for (; i < segments.length; i++) {
72+
if (globMagicChars.test(segments[i])) {
73+
break;
74+
}
75+
staticSegments.push(segments[i]);
76+
}
77+
// If no segment contains magic chars, treat the path as a literal file and
78+
// use its parent directory as the base.
79+
if (i === segments.length) {
80+
staticSegments.pop();
81+
}
82+
const base = staticSegments.join(path.sep) || path.sep;
83+
const pattern = segments.slice(staticSegments.length).join("/");
84+
return {base, pattern};
85+
}
86+
5887
export class BundleFileSet {
5988
public readonly bundleDataCache: CachedValue<BundleSchema> =
6089
new CachedValue<BundleSchema>(async () => {
@@ -120,6 +149,41 @@ export class BundleFileSet {
120149
return [...new Set(allFiles)].map((f) => Uri.file(f));
121150
}
122151

152+
/**
153+
* Returns watch targets for include patterns whose static base resolves
154+
* outside the active project root (e.g. "../../shared/*.yml"). The default
155+
* recursive workspace watcher only observes files under the project root,
156+
* so these external bases need dedicated watchers. Each target is a base
157+
* directory plus a relative glob suitable for a vscode RelativePattern.
158+
*/
159+
async getExternalIncludeWatchTargets(): Promise<
160+
{baseUri: Uri; pattern: string}[]
161+
> {
162+
const patterns = await this.getIncludePatterns();
163+
const projectRoot = path.normalize(this.projectRoot.fsPath);
164+
const targets = new Map<string, {baseUri: Uri; pattern: string}>();
165+
166+
for (const pattern of patterns) {
167+
const resolved = path.resolve(projectRoot, pattern);
168+
const {base, pattern: relativePattern} = splitGlobBase(resolved);
169+
// Keep only bases that escape the project root. The default
170+
// recursive watcher already covers everything under the root.
171+
const relativeToRoot = path.relative(projectRoot, base);
172+
if (!relativeToRoot.startsWith("..")) {
173+
continue;
174+
}
175+
const key = `${base}\0${relativePattern}`;
176+
if (!targets.has(key)) {
177+
targets.set(key, {
178+
baseUri: Uri.file(base),
179+
pattern: relativePattern,
180+
});
181+
}
182+
}
183+
184+
return [...targets.values()];
185+
}
186+
123187
async allFiles() {
124188
const rootFile = await this.getRootFile();
125189
if (rootFile === undefined) {

packages/databricks-vscode/src/bundle/BundleWatcher.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
import {Disposable, EventEmitter, Uri, workspace} from "vscode";
1+
import {
2+
Disposable,
3+
EventEmitter,
4+
RelativePattern,
5+
Uri,
6+
workspace,
7+
} from "vscode";
28
import {BundleFileSet, getAbsoluteGlobPath} from "./BundleFileSet";
39
import path from "path";
410
import {WorkspaceFolderManager} from "../vscode-objs/WorkspaceFolderManager";
11+
import {Mutex} from "../locking";
512

613
export class BundleWatcher implements Disposable {
714
private disposables: Disposable[] = [];
@@ -19,15 +26,24 @@ export class BundleWatcher implements Disposable {
1926
public readonly onDidDelete = this._onDidDelete.event;
2027

2128
private initCleanup: Disposable;
29+
// Watchers for include files that resolve outside the active project root.
30+
// Tracked separately so they can be rebuilt when the include list changes
31+
// without tearing down the main in-tree watcher.
32+
private externalWatchersCleanup: Disposable | undefined;
33+
// Serializes refreshExternalWatchers() so overlapping invocations (e.g. a
34+
// burst of root-file changes) don't leak watchers or create duplicates.
35+
private readonly externalWatchersMutex = new Mutex();
2236
constructor(
2337
private readonly bundleFileSet: BundleFileSet,
2438
private readonly workspaceFolderManager: WorkspaceFolderManager
2539
) {
2640
this.initCleanup = this.init();
41+
void this.refreshExternalWatchers();
2742
this.disposables.push(
2843
this.workspaceFolderManager.onDidChangeActiveProjectFolder(() => {
2944
this.initCleanup.dispose();
3045
this.initCleanup = this.init();
46+
void this.refreshExternalWatchers();
3147
this.bundleFileSet.bundleDataCache.invalidate();
3248
})
3349
);
@@ -61,6 +77,51 @@ export class BundleWatcher implements Disposable {
6177
};
6278
}
6379

80+
/**
81+
* (Re)create watchers for include files that live outside the active
82+
* project root. The default recursive watcher created in init() only
83+
* observes files under the project root, so parent-folder includes
84+
* (e.g. "../../shared/databricks-shared.yml") need dedicated watchers
85+
* to keep the bundle cache and downstream models in sync.
86+
*/
87+
private async refreshExternalWatchers() {
88+
return this.externalWatchersMutex.synchronise(async () => {
89+
this.externalWatchersCleanup?.dispose();
90+
this.externalWatchersCleanup = undefined;
91+
92+
const targets =
93+
await this.bundleFileSet.getExternalIncludeWatchTargets();
94+
if (targets.length === 0) {
95+
return;
96+
}
97+
98+
const disposables: Disposable[] = [];
99+
for (const {baseUri, pattern} of targets) {
100+
const watcher = workspace.createFileSystemWatcher(
101+
new RelativePattern(baseUri, pattern)
102+
);
103+
disposables.push(
104+
watcher,
105+
watcher.onDidCreate((e) => {
106+
this.yamlFileChangeHandler(e, "CREATE");
107+
}),
108+
watcher.onDidChange((e) => {
109+
this.yamlFileChangeHandler(e, "CHANGE");
110+
}),
111+
watcher.onDidDelete((e) => {
112+
this.yamlFileChangeHandler(e, "DELETE");
113+
})
114+
);
115+
}
116+
117+
this.externalWatchersCleanup = {
118+
dispose: () => {
119+
disposables.forEach((i) => i.dispose());
120+
},
121+
};
122+
});
123+
}
124+
64125
private async yamlFileChangeHandler(
65126
e: Uri,
66127
type: "CREATE" | "CHANGE" | "DELETE"
@@ -74,6 +135,9 @@ export class BundleWatcher implements Disposable {
74135
// to provide additional granularity, we also fire an event when the root bundle file changes
75136
if (this.bundleFileSet.isRootBundleFile(e)) {
76137
this._onDidChangeRootFile.fire();
138+
// The include list lives in the root file, so its set of external
139+
// include locations may have changed. Rebuild those watchers.
140+
void this.refreshExternalWatchers();
77141
}
78142
switch (type) {
79143
case "CREATE":
@@ -88,5 +152,6 @@ export class BundleWatcher implements Disposable {
88152
dispose() {
89153
this.disposables.forEach((i) => i.dispose());
90154
this.initCleanup.dispose();
155+
this.externalWatchersCleanup?.dispose();
91156
}
92157
}

0 commit comments

Comments
 (0)