Repository navigation
fix(server): storage cleanup removes worktrees whose submodule tags the remote has #32312
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
382aebb
859f43c
c388a10
80821ca
1299f3d
5309d5a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,6 +47,7 @@ import * as Settings from "./serverSettings.ts"; | |
| import * as TerminalManager from "./terminal/Manager.ts"; | ||
| import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; | ||
| import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | ||
| import { NON_INTERACTIVE_GIT_ENV } from "./vcs/GitVcsDriverCore.ts"; | ||
| import { removeLinkedWorktree } from "./vcs/worktreeRemoval.ts"; | ||
| import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; | ||
| import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
|
|
@@ -677,8 +678,99 @@ const make = Effect.gen(function* () { | |
| return file === undefined ? null : `has ignored files (${file})`; | ||
| }); | ||
|
|
||
| const unpushedHistoryReason = Effect.fn("StorageCleanup.unpushedHistoryReason")(function* ( | ||
| cwd: string, | ||
| directory: string, | ||
| remoteTags: Map<string, ReadonlySet<string> | null>, | ||
| ) { | ||
| const inspect = (args: string[], options?: Partial<GitVcsDriver.ExecuteGitInput>) => | ||
| git.execute({ | ||
| operation: "StorageCleanup.submodules", | ||
| cwd, | ||
| args: ["--git-dir", directory, "--work-tree", cwd, ...args], | ||
| maxOutputBytes: 64 * 1024, | ||
| ...options, | ||
| }); | ||
| const exceeded = "submodule inspection exceeded the limit"; | ||
| const unpushed = "submodule has commits not on a remote-tracking ref"; | ||
| const reachable = Effect.fnUntraced(function* (stdin?: string) { | ||
| const result = yield* inspect( | ||
| [ | ||
| "rev-list", | ||
| "--max-count=1", | ||
| ...(stdin === undefined ? [] : ["--stdin"]), | ||
| "HEAD", | ||
| "--all", | ||
| "--reflog", | ||
| "--not", | ||
| "--remotes", | ||
| ], | ||
| stdin === undefined ? {} : { stdin }, | ||
| ); | ||
| if (result.stdoutTruncated || result.stderrTruncated) return exceeded; | ||
| return result.stdout.trim() === "" ? null : unpushed; | ||
| }); | ||
| const all = yield* reachable(); | ||
| if (all !== unpushed) return all; | ||
| // Clones fetch every upstream tag, so a tag a remote has at the same object is pushed. | ||
| const tags = yield* inspect( | ||
| ["for-each-ref", "--format=%(objectname)%09%(refname)", "refs/tags"], | ||
| { | ||
| maxOutputBytes: 16 * 1024 * 1024, | ||
| }, | ||
| ); | ||
| const remotes = yield* inspect(["remote"]); | ||
| const worktree = yield* inspect(["config", "core.worktree"], { allowNonZeroExit: true }); | ||
| if (tags.stdoutTruncated || tags.stderrTruncated || remotes.stdoutTruncated) return exceeded; | ||
| // Relative remote paths resolve from the submodule's work tree. Deinit unsets it. | ||
| const base = path.resolve(directory, worktree.stdout.trim() || "."); | ||
| const local = tags.stdout.split("\n").filter(Boolean); | ||
| const pushed = new Set<string>(); | ||
| for (const remote of remotes.stdout.split("\n").filter(Boolean)) { | ||
| if (pushed.size === local.length) break; | ||
| const url = yield* inspect(["ls-remote", "--get-url", "--", remote]); | ||
| if (url.stdoutTruncated) return exceeded; | ||
| const location = url.stdout.trim(); | ||
| const key = | ||
| path.isAbsolute(location) || /^[^/]*:/.test(location) | ||
| ? location | ||
| : path.resolve(base, location); | ||
| let published = remoteTags.get(key); | ||
| if (published === undefined) { | ||
| published = yield* git | ||
| .execute({ | ||
| operation: "StorageCleanup.remoteTags", | ||
| cwd: base, | ||
| args: ["--git-dir", directory, "ls-remote", "--tags", "--refs", "--", remote], | ||
| env: NON_INTERACTIVE_GIT_ENV, | ||
| timeoutMs: 20_000, | ||
| maxOutputBytes: 16 * 1024 * 1024, | ||
| }) | ||
| .pipe( | ||
| Effect.map((result) => | ||
| result.stdoutTruncated ? null : new Set(result.stdout.split("\n")), | ||
| ), | ||
| // An unreachable remote vouches for no tags. | ||
| Effect.orElseSucceed(() => new Set<string>()), | ||
| ); | ||
| remoteTags.set(key, published); | ||
| } | ||
| if (published === null) return exceeded; | ||
| for (const line of local) if (published.has(line)) pushed.add(line); | ||
| } | ||
| if (pushed.size === 0) return unpushed; | ||
| return yield* reachable( | ||
| // Exclude the verified objects, not the names: a tag can move after the listing. | ||
| [...pushed].map((line) => `^${line.slice(0, line.indexOf("\t"))}\n`).join(""), | ||
| ); | ||
| }); | ||
|
|
||
| const submoduleRemoval = Effect.fn("StorageCleanup.submoduleRemoval")( | ||
| function* (cwd: string, rules: WorktreeCleanupRules) { | ||
| function* ( | ||
| cwd: string, | ||
| rules: WorktreeCleanupRules, | ||
| remoteTags: Map<string, ReadonlySet<string> | null>, | ||
| ) { | ||
| const inspect = (args: string[], maxOutputBytes = 64 * 1024) => | ||
| git.execute({ | ||
| operation: "StorageCleanup.submodules", | ||
|
|
@@ -722,23 +814,8 @@ const make = Effect.gen(function* () { | |
| if ((yield* fs.stat(directory)).type !== "Directory") | ||
| return { force: false, reason: "submodule repository could not be inspected" }; | ||
| if (yield* fs.exists(path.join(directory, "HEAD"))) { | ||
| const unpushed = yield* inspect([ | ||
| "--git-dir", | ||
| directory, | ||
| "--work-tree", | ||
| cwd, | ||
| "rev-list", | ||
| "--max-count=1", | ||
| "HEAD", | ||
| "--all", | ||
| "--reflog", | ||
| "--not", | ||
| "--remotes", | ||
| ]); | ||
| if (unpushed.stdoutTruncated || unpushed.stderrTruncated) | ||
| return { force: false, reason: "submodule inspection exceeded the limit" }; | ||
| if (unpushed.stdout.trim() !== "") | ||
| return { force: false, reason: "submodule has commits not on a remote-tracking ref" }; | ||
| const unpushed = yield* unpushedHistoryReason(cwd, directory, remoteTags); | ||
| if (unpushed !== null) return { force: false, reason: unpushed }; | ||
| pending.push(path.join(directory, "modules")); | ||
| } else { | ||
| for (const name of yield* fs.readDirectory(directory)) | ||
|
|
@@ -865,6 +942,7 @@ const make = Effect.gen(function* () { | |
| const threadsById = new Map(snapshot.threads.map((thread) => [thread.id, thread])); | ||
| const refreshedDefaultRefs = new Map<string, Set<string>>(); | ||
| const defaultBranches = new Map<string, string | null>(); | ||
| const remoteTags = new Map<string, ReadonlySet<string> | null>(); | ||
| const groups = Map.groupBy( | ||
| snapshot.threads.filter((thread) => thread.worktreePath !== null), | ||
| (thread) => path.resolve(thread.worktreePath!), | ||
|
|
@@ -1209,7 +1287,7 @@ const make = Effect.gen(function* () { | |
| // Clean only untracked files. Ordinary worktrees retain Git's final tracked-edit check. | ||
| if (settings.worktreeKeepWhen === "tracked-changes") | ||
| yield* cleanupGit(worktreePath, ["clean", "-ffdx"], "git clean"); | ||
| const submodules = yield* submoduleRemoval(worktreePath, settings); | ||
| const submodules = yield* submoduleRemoval(worktreePath, settings, remoteTags); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if (submodules.reason !== null) return keep(submodules.reason); | ||
| if (submodules.force) { | ||
| const changes = yield* localChangesReason(worktreePath, settings); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: pingdotgg/t3code
Length of output: 1181
Script executed:
Repository: pingdotgg/t3code
Length of output: 10102
Pass
appendTruncationMarker: trueto thels-remotecall.Without this option, output over 16 MiB fails the command instead of marking it truncated. The
result.stdoutTruncated ? nullcheck never triggers, and the failure is cached as an empty tag set. The worktree is still kept, but the report says the commits are not on a remote-tracking ref instead of reporting that the inspection limit was exceeded. Setting the option makes the truncation branch reachable and returnsexceededfor oversized output.The other
stdoutTruncatedchecks in this file have the same gap, because no call in the file sets the option. Their overflow surfaces as a command error instead of a truncation result.Suggested fix
maxOutputBytes: 16 * 1024 * 1024, + appendTruncationMarker: true,Committable suggestion