Move goose2 (#8516)
Signed-off-by: Jack Amadeo <jackamadeo@squareup.com> Co-authored-by: block-open-source[bot] <201011344+block-open-source[bot]@users.noreply.github.com> Co-authored-by: block-open-source[bot] <1159699+block-open-source[bot]@users.noreply.github.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: tulsi <tulsi@block.xyz> Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Bradley Axen <baxen@squareup.com> Co-authored-by: Alex Hancock <alexhancock@block.xyz> Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Nahiyan Khan <nahiyan.khan@gmail.com> Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
// Add narrowly scoped exceptions here with justification
|
||||
const EXCEPTIONS = {
|
||||
"src/features/sidebar/ui/SidebarProjectsSection.tsx": {
|
||||
limit: 560,
|
||||
justification:
|
||||
"Drag-and-drop handlers plus activeProjectId highlight for draft-in-project sessions.",
|
||||
},
|
||||
"src/features/chat/ui/ChatView.tsx": {
|
||||
limit: 535,
|
||||
justification:
|
||||
"ACP prewarm guards, project-aware working dir selection, working context sync, and chat bootstrapping still live together here.",
|
||||
},
|
||||
"src/features/chat/ui/__tests__/ContextPanel.test.tsx": {
|
||||
limit: 550,
|
||||
justification:
|
||||
"Workspace widget integration tests cover branch switching, worktree creation, dirty-state dialogs, and picker interactions.",
|
||||
},
|
||||
"src/features/sidebar/ui/Sidebar.tsx": {
|
||||
limit: 580,
|
||||
justification:
|
||||
"Search-as-you-type filtering and draft-aware sidebar highlight logic.",
|
||||
},
|
||||
"src/app/AppShell.tsx": {
|
||||
limit: 640,
|
||||
justification:
|
||||
"Shell still coordinates ACP session loading, project reassignment, and app-level chat routing.",
|
||||
},
|
||||
"src/features/chat/hooks/useAcpStream.ts": {
|
||||
limit: 580,
|
||||
justification:
|
||||
"ACP replay, streaming, session binding, model-state event handling, and replay timeout are still centralized here.",
|
||||
},
|
||||
"src/features/chat/hooks/__tests__/useAcpStream.test.ts": {
|
||||
limit: 570,
|
||||
justification:
|
||||
"Covers replay buffering, timeout error state, streaming edge cases, and provider identity persistence in one cohesive suite.",
|
||||
},
|
||||
"src/features/chat/stores/__tests__/chatSessionStore.test.ts": {
|
||||
limit: 540,
|
||||
justification:
|
||||
"ACP session overlay regressions currently need one broad integration-style store suite.",
|
||||
},
|
||||
"src/features/chat/stores/chatSessionStore.ts": {
|
||||
limit: 640,
|
||||
justification:
|
||||
"ACP-backed session overlay persistence, draft migration, and sidebar-facing session merge logic live together for now.",
|
||||
},
|
||||
"src-tauri/src/services/acp/manager/dispatcher.rs": {
|
||||
limit: 540,
|
||||
justification:
|
||||
"ACP replay and live-stream event fan-out share one dispatcher with replay event counting for drain stabilisation.",
|
||||
},
|
||||
"src-tauri/src/services/acp/manager.rs": {
|
||||
limit: 630,
|
||||
justification:
|
||||
"ACP manager command dispatch loop — export/import/fork session ext_method dispatch adds boilerplate.",
|
||||
},
|
||||
"src-tauri/src/services/acp/manager/session_ops.rs": {
|
||||
limit: 620,
|
||||
justification:
|
||||
"Session prepare/load/list logic, working-dir updates, wait_for_replay_drain helper with iteration cap, and composite prepared-session reuse remain colocated while ACP session ownership stabilizes.",
|
||||
},
|
||||
};
|
||||
|
||||
// Directories excluded from size checks (imported library code)
|
||||
const EXCLUDED_DIRS = [
|
||||
"src/shared/ui",
|
||||
"src/components/ai-elements",
|
||||
"src/hooks",
|
||||
];
|
||||
|
||||
const DIRS_TO_CHECK = [
|
||||
{ dir: "src/app", glob: /\.[jt]sx?$/ },
|
||||
{ dir: "src/features", glob: /\.[jt]sx?$/ },
|
||||
{ dir: "src/shared", glob: /\.[jt]sx?$/ },
|
||||
{ dir: "src/components", glob: /\.[jt]sx?$/ },
|
||||
{ dir: "src/hooks", glob: /\.[jt]sx?$/ },
|
||||
{ dir: "src-tauri/src", glob: /\.rs$/ },
|
||||
];
|
||||
|
||||
function countLines(filePath) {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
return content.split("\n").length;
|
||||
}
|
||||
|
||||
function isExcluded(filePath) {
|
||||
const rel = relative(".", filePath);
|
||||
return EXCLUDED_DIRS.some((dir) => rel.startsWith(dir));
|
||||
}
|
||||
|
||||
function walkDir(dir, pattern) {
|
||||
const results = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...walkDir(fullPath, pattern));
|
||||
} else if (pattern.test(entry.name)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
|
||||
for (const { dir, glob } of DIRS_TO_CHECK) {
|
||||
const files = walkDir(dir, glob);
|
||||
for (const file of files) {
|
||||
if (isExcluded(file)) continue;
|
||||
const rel = relative(".", file);
|
||||
const limit = EXCEPTIONS[rel]?.limit ?? DEFAULT_LIMIT;
|
||||
const lines = countLines(file);
|
||||
if (lines > limit) {
|
||||
violations.push({ file: rel, lines, limit });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("Desktop file size check failed:");
|
||||
for (const v of violations) {
|
||||
console.error(` - ${v.file}: ${v.lines} lines (limit ${v.limit})`);
|
||||
}
|
||||
console.error(
|
||||
"\nSplit the file or add a narrowly scoped exception in `scripts/check-file-sizes.mjs`.",
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("File size check passed.");
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { extname, join, relative } from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
const CHECKED_PATHS = [
|
||||
"src/app/ui",
|
||||
"src/features/agents",
|
||||
"src/features/chat/ui",
|
||||
"src/features/home",
|
||||
"src/features/projects",
|
||||
"src/features/settings",
|
||||
"src/features/skills",
|
||||
"src/features/sidebar",
|
||||
"src/features/status",
|
||||
"src/features/sessions",
|
||||
"src/shared/ui/ai-elements/code-block.tsx",
|
||||
"src/shared/ui/ai-elements/environment-variables.tsx",
|
||||
"src/shared/ui/ai-elements/message.tsx",
|
||||
"src/shared/ui/ai-elements/plan.tsx",
|
||||
"src/shared/ui/ai-elements/snippet.tsx",
|
||||
"src/shared/ui/ai-elements/stack-trace.tsx",
|
||||
"src/shared/ui/ai-elements/terminal.tsx",
|
||||
"src/shared/ui/ai-elements/context.tsx",
|
||||
"src/shared/ui/ai-elements/commit.tsx",
|
||||
];
|
||||
|
||||
const EXCLUDED_PATH_SEGMENTS = ["__tests__"];
|
||||
const EXCLUDED_FILE_MARKERS = [".test.", ".spec."];
|
||||
const CHECKED_EXTENSIONS = new Set([".ts", ".tsx"]);
|
||||
const TEXT_ATTRIBUTE_NAMES = new Set([
|
||||
"aria-label",
|
||||
"title",
|
||||
"placeholder",
|
||||
"alt",
|
||||
]);
|
||||
const TEXT_EXCLUDED_TAGS = new Set(["code", "pre", "kbd"]);
|
||||
const IGNORE_COMMENT = "i18n-check-ignore";
|
||||
|
||||
function walkPath(targetPath) {
|
||||
const entries = [];
|
||||
let statEntries;
|
||||
try {
|
||||
statEntries = readdirSync(targetPath, { withFileTypes: true });
|
||||
} catch {
|
||||
if (CHECKED_EXTENSIONS.has(extname(targetPath))) {
|
||||
return [targetPath];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const entry of statEntries) {
|
||||
const fullPath = join(targetPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
entries.push(...walkPath(fullPath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CHECKED_EXTENSIONS.has(extname(entry.name))) {
|
||||
entries.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isExcluded(filePath) {
|
||||
const rel = relative(".", filePath);
|
||||
const normalizedRel = rel.replace(/\\/g, "/");
|
||||
const pathSegments = normalizedRel.split("/");
|
||||
|
||||
return (
|
||||
EXCLUDED_PATH_SEGMENTS.some((segment) => pathSegments.includes(segment)) ||
|
||||
EXCLUDED_FILE_MARKERS.some((marker) => normalizedRel.includes(marker))
|
||||
);
|
||||
}
|
||||
|
||||
function collapseWhitespace(text) {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isProbablyUserFacingText(text) {
|
||||
if (!text) return false;
|
||||
if (!/\p{L}/u.test(text)) return false;
|
||||
if (/^https?:\/\//.test(text)) return false;
|
||||
if (/^[./~@#][\w./-]+$/.test(text)) return false;
|
||||
if (/^[A-Z0-9_:-]+$/.test(text)) return false;
|
||||
if (/^[\w.-]+\.[A-Za-z]{2,}$/.test(text)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getLineText(sourceText, sourceFile, lineIndex) {
|
||||
if (lineIndex < 0) return "";
|
||||
|
||||
const lineStarts = sourceFile.getLineStarts();
|
||||
if (lineIndex >= lineStarts.length) return "";
|
||||
|
||||
const lineStart = lineStarts[lineIndex];
|
||||
const lineEnd =
|
||||
lineIndex + 1 < lineStarts.length
|
||||
? lineStarts[lineIndex + 1]
|
||||
: sourceText.length;
|
||||
|
||||
return sourceText.slice(lineStart, lineEnd);
|
||||
}
|
||||
|
||||
function hasIgnoreComment(sourceText, sourceFile, node) {
|
||||
const start = node.getStart(sourceFile);
|
||||
const { line } = sourceFile.getLineAndCharacterOfPosition(start);
|
||||
|
||||
return [line - 1, line].some((lineIndex) =>
|
||||
getLineText(sourceText, sourceFile, lineIndex).includes(IGNORE_COMMENT),
|
||||
);
|
||||
}
|
||||
|
||||
function getParentTagName(node) {
|
||||
if (ts.isJsxElement(node.parent)) {
|
||||
return node.parent.openingElement.tagName.getText();
|
||||
}
|
||||
|
||||
if (ts.isJsxSelfClosingElement(node.parent)) {
|
||||
return node.parent.tagName.getText();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeJsxText(text) {
|
||||
return collapseWhitespace(text);
|
||||
}
|
||||
|
||||
function extractStringFromExpression(expression) {
|
||||
if (!expression) return null;
|
||||
|
||||
if (ts.isStringLiteralLike(expression)) {
|
||||
return expression.text;
|
||||
}
|
||||
|
||||
if (ts.isNoSubstitutionTemplateLiteral(expression)) {
|
||||
return expression.text;
|
||||
}
|
||||
|
||||
if (ts.isTemplateExpression(expression)) {
|
||||
let text = expression.head.text;
|
||||
for (const span of expression.templateSpans) {
|
||||
text += `{${span.expression.getText()}}${span.literal.text}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatLocation(sourceFile, position) {
|
||||
const { line, character } =
|
||||
sourceFile.getLineAndCharacterOfPosition(position);
|
||||
return `${sourceFile.fileName}:${line + 1}:${character + 1}`;
|
||||
}
|
||||
|
||||
function collectViolations(filePath) {
|
||||
const sourceText = readFileSync(filePath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
const violations = [];
|
||||
|
||||
function report(node, kind, text) {
|
||||
const normalizedText = collapseWhitespace(text);
|
||||
if (!isProbablyUserFacingText(normalizedText)) return;
|
||||
if (hasIgnoreComment(sourceText, sourceFile, node)) return;
|
||||
|
||||
violations.push({
|
||||
location: formatLocation(sourceFile, node.getStart(sourceFile)),
|
||||
kind,
|
||||
text: normalizedText,
|
||||
});
|
||||
}
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isJsxText(node)) {
|
||||
const tagName = getParentTagName(node);
|
||||
if (tagName && TEXT_EXCLUDED_TAGS.has(tagName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = normalizeJsxText(node.getText(sourceFile));
|
||||
report(node, "jsx-text", text);
|
||||
}
|
||||
|
||||
if (ts.isJsxAttribute(node)) {
|
||||
const attributeName = node.name.text;
|
||||
if (TEXT_ATTRIBUTE_NAMES.has(attributeName) && node.initializer) {
|
||||
if (ts.isStringLiteral(node.initializer)) {
|
||||
report(
|
||||
node.initializer,
|
||||
`prop:${attributeName}`,
|
||||
node.initializer.text,
|
||||
);
|
||||
}
|
||||
|
||||
if (ts.isJsxExpression(node.initializer)) {
|
||||
const text = extractStringFromExpression(node.initializer.expression);
|
||||
if (text) {
|
||||
report(node.initializer, `prop:${attributeName}`, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ts.isJsxExpression(node) && node.expression) {
|
||||
if (ts.isJsxAttribute(node.parent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = extractStringFromExpression(node.expression);
|
||||
if (text) {
|
||||
report(node, "jsx-expression", text);
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
return violations;
|
||||
}
|
||||
|
||||
const files = CHECKED_PATHS.flatMap(walkPath)
|
||||
.filter((filePath) => !isExcluded(filePath))
|
||||
.sort();
|
||||
|
||||
const violations = files.flatMap((filePath) => collectViolations(filePath));
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("i18n string check failed:");
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
` - ${violation.location} [${violation.kind}] ${JSON.stringify(violation.text)}`,
|
||||
);
|
||||
}
|
||||
console.error("");
|
||||
console.error(
|
||||
`Wrap user-facing strings in translations or annotate a narrow exception with "${IGNORE_COMMENT}".`,
|
||||
);
|
||||
console.error(
|
||||
"The current enforcement scope is intentionally limited to app areas already migrated to i18n.",
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("i18n string check passed.");
|
||||
}
|
||||
Executable
+262
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ensure-local-goose.sh [--print-bin | --check-bin]
|
||||
|
||||
Syncs and builds a dedicated local goose checkout for goose2 development.
|
||||
|
||||
Environment variables:
|
||||
GOOSE_DEV_MODE auto|required (default: auto)
|
||||
GOOSE_DEV_ROOT path to the shared goose2 dev cache root
|
||||
(default: platform cache dir under home)
|
||||
GOOSE_DEV_REPO path to the managed goose checkout
|
||||
(default: $GOOSE_DEV_ROOT/goose)
|
||||
GOOSE_DEV_STAMP_FILE path to the shared build stamp file
|
||||
(default: $GOOSE_DEV_ROOT/stamp.env)
|
||||
GOOSE_DEV_CLONE_URL git clone URL for the managed goose checkout
|
||||
(default: https://github.com/block/goose.git)
|
||||
GOOSE_DEV_REMOTE git remote to sync from (default: origin)
|
||||
GOOSE_DEV_BRANCH preferred branch to use (default: baxen/goose2)
|
||||
GOOSE_DEV_FALLBACK_BRANCH fallback branch when the preferred branch does
|
||||
not exist remotely (default: main)
|
||||
GOOSE_DEV_ALLOW_DIRTY 1 to allow syncing/building a dirty checkout
|
||||
EOF
|
||||
}
|
||||
|
||||
action="build"
|
||||
print_bin=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--print-bin)
|
||||
print_bin=1
|
||||
shift
|
||||
;;
|
||||
--check-bin)
|
||||
action="check"
|
||||
print_bin=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mode="${GOOSE_DEV_MODE:-auto}"
|
||||
clone_url="${GOOSE_DEV_CLONE_URL:-https://github.com/block/goose.git}"
|
||||
remote="${GOOSE_DEV_REMOTE:-origin}"
|
||||
preferred_branch="${GOOSE_DEV_BRANCH:-baxen/goose2}"
|
||||
fallback_branch="${GOOSE_DEV_FALLBACK_BRANCH:-main}"
|
||||
allow_dirty="${GOOSE_DEV_ALLOW_DIRTY:-0}"
|
||||
|
||||
log() {
|
||||
echo "[goose-dev] $*" >&2
|
||||
}
|
||||
|
||||
fail_or_skip() {
|
||||
local message="$1"
|
||||
if [[ "${mode}" == "required" ]]; then
|
||||
echo "${message}" >&2
|
||||
exit 1
|
||||
fi
|
||||
log "${message}"
|
||||
# In check mode, exit 2 so callers (e.g. just dev) can detect "not ready"
|
||||
# and block instead of silently continuing without a goose binary.
|
||||
if [[ "${action}" == "check" ]]; then
|
||||
exit 2
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
default_goose_dev_root() {
|
||||
if [[ -n "${XDG_CACHE_HOME:-}" ]]; then
|
||||
printf '%s/goose2-dev\n' "${XDG_CACHE_HOME}"
|
||||
return
|
||||
fi
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
printf '%s/Library/Caches/goose2-dev\n' "${HOME}"
|
||||
;;
|
||||
*)
|
||||
printf '%s/.cache/goose2-dev\n' "${HOME}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
goose_dev_root="${GOOSE_DEV_ROOT:-$(default_goose_dev_root)}"
|
||||
goose_repo="${GOOSE_DEV_REPO:-${goose_dev_root}/goose}"
|
||||
stamp_file="${GOOSE_DEV_STAMP_FILE:-${goose_dev_root}/stamp.env}"
|
||||
bin_path="${goose_repo}/target/debug/goose"
|
||||
|
||||
resolve_remote_head() {
|
||||
local branch_name="$1"
|
||||
git -C "${goose_repo}" ls-remote --heads "${remote}" "${branch_name}" 2>/dev/null | awk 'NR == 1 { print $1 }'
|
||||
}
|
||||
|
||||
resolve_branch() {
|
||||
local resolved_branch="${preferred_branch}"
|
||||
local resolved_head
|
||||
resolved_head="$(resolve_remote_head "${resolved_branch}")"
|
||||
|
||||
if [[ -z "${resolved_head}" && "${resolved_branch}" != "${fallback_branch}" ]]; then
|
||||
log "Remote branch ${remote}/${resolved_branch} not found; falling back to ${remote}/${fallback_branch}."
|
||||
resolved_branch="${fallback_branch}"
|
||||
resolved_head="$(resolve_remote_head "${resolved_branch}")"
|
||||
fi
|
||||
|
||||
if [[ -z "${resolved_head}" ]]; then
|
||||
if [[ "${mode}" == "required" ]]; then
|
||||
echo "Could not resolve ${remote}/${resolved_branch} for managed goose checkout at ${goose_repo}." >&2
|
||||
return 1
|
||||
fi
|
||||
log "Could not resolve ${remote}/${resolved_branch} for managed goose checkout at ${goose_repo}."
|
||||
return 2
|
||||
fi
|
||||
|
||||
RESOLVED_BRANCH="${resolved_branch}"
|
||||
RESOLVED_REMOTE_HEAD="${resolved_head}"
|
||||
return 0
|
||||
}
|
||||
|
||||
write_stamp() {
|
||||
local branch_name="$1"
|
||||
local commit_sha="$2"
|
||||
|
||||
mkdir -p "$(dirname "${stamp_file}")"
|
||||
{
|
||||
printf 'STAMP_REPO=%q\n' "${goose_repo}"
|
||||
printf 'STAMP_BRANCH=%q\n' "${branch_name}"
|
||||
printf 'STAMP_COMMIT=%q\n' "${commit_sha}"
|
||||
printf 'STAMP_BIN=%q\n' "${bin_path}"
|
||||
} >"${stamp_file}"
|
||||
}
|
||||
|
||||
ensure_checkout_exists() {
|
||||
if [[ -d "${goose_repo}/.git" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${action}" == "check" ]]; then
|
||||
fail_or_skip "Managed goose checkout not found at ${goose_repo}. Rerun just setup."
|
||||
fi
|
||||
|
||||
log "Cloning managed goose checkout into ${goose_repo}."
|
||||
mkdir -p "$(dirname "${goose_repo}")"
|
||||
git clone "${clone_url}" "${goose_repo}" >/dev/null 2>&1 || {
|
||||
fail_or_skip "Failed to clone managed goose checkout from ${clone_url} into ${goose_repo}."
|
||||
}
|
||||
}
|
||||
|
||||
ensure_checkout_exists
|
||||
|
||||
if [[ "${allow_dirty}" != "1" ]]; then
|
||||
if [[ -n "$(git -C "${goose_repo}" status --porcelain)" ]]; then
|
||||
fail_or_skip "Managed goose checkout at ${goose_repo} is dirty. Use a dedicated checkout or set GOOSE_DEV_ALLOW_DIRTY=1."
|
||||
fi
|
||||
fi
|
||||
|
||||
if resolve_branch; then
|
||||
branch="${RESOLVED_BRANCH}"
|
||||
remote_head="${RESOLVED_REMOTE_HEAD}"
|
||||
else
|
||||
resolve_branch_status=$?
|
||||
case "${resolve_branch_status}" in
|
||||
1)
|
||||
exit 1
|
||||
;;
|
||||
2)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected resolve_branch status: ${resolve_branch_status}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ "${action}" == "check" ]]; then
|
||||
if [[ ! -f "${stamp_file}" ]]; then
|
||||
fail_or_skip "Managed goose checkout is configured, but no local goose build stamp was found. Rerun just setup."
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "${stamp_file}"
|
||||
|
||||
if [[ "${STAMP_REPO:-}" != "${goose_repo}" ]]; then
|
||||
fail_or_skip "Managed goose checkout changed since the last local goose build. Rerun just setup."
|
||||
fi
|
||||
|
||||
if [[ "${STAMP_BRANCH:-}" != "${branch}" ]]; then
|
||||
fail_or_skip "Managed goose branch is now ${branch}, but the local goose build was prepared for ${STAMP_BRANCH:-unknown}. Rerun just setup."
|
||||
fi
|
||||
|
||||
if [[ ! -x "${STAMP_BIN:-}" ]]; then
|
||||
fail_or_skip "Local goose binary was not found at ${STAMP_BIN:-unknown}. Rerun just setup."
|
||||
fi
|
||||
|
||||
local_head="$(git -C "${goose_repo}" rev-parse HEAD)"
|
||||
if [[ "${STAMP_COMMIT:-}" != "${local_head}" ]]; then
|
||||
fail_or_skip "Managed goose checkout changed after the last local build. Rerun just setup."
|
||||
fi
|
||||
|
||||
if [[ "${STAMP_COMMIT:-}" != "${remote_head}" ]]; then
|
||||
fail_or_skip "Managed goose checkout is behind ${remote}/${branch}. Rerun just setup."
|
||||
fi
|
||||
|
||||
if [[ "${print_bin}" == "1" ]]; then
|
||||
printf '%s\n' "${STAMP_BIN}"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git -C "${goose_repo}" fetch "${remote}" "${branch}" >/dev/null 2>&1
|
||||
|
||||
remote_ref="refs/remotes/${remote}/${branch}"
|
||||
if ! git -C "${goose_repo}" show-ref --verify --quiet "${remote_ref}"; then
|
||||
fail_or_skip "Fetched ${remote}/${branch}, but ${remote_ref} is not available in ${goose_repo}."
|
||||
fi
|
||||
|
||||
if git -C "${goose_repo}" show-ref --verify --quiet "refs/heads/${branch}"; then
|
||||
git -C "${goose_repo}" checkout "${branch}" >/dev/null 2>&1
|
||||
else
|
||||
git -C "${goose_repo}" checkout -b "${branch}" --track "${remote}/${branch}" >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
# Reset to the remote head. This is a managed build-only checkout, so we
|
||||
# always want to match the remote exactly. A plain `pull --ff-only` would
|
||||
# fail when the remote branch has been force-pushed (rebased/amended).
|
||||
git -C "${goose_repo}" reset --hard "${remote}/${branch}" >/dev/null 2>&1
|
||||
|
||||
log "Building goose from ${goose_repo} on ${branch}."
|
||||
(
|
||||
cd "${goose_repo}"
|
||||
cargo build -p goose-cli --bin goose
|
||||
)
|
||||
|
||||
if [[ -n "$(git -C "${goose_repo}" status --porcelain -- Cargo.lock)" ]]; then
|
||||
# Cargo may refresh the lockfile while compiling a freshly synced checkout.
|
||||
# This managed repo is only a build source for goose2, so restore the tracked
|
||||
# lockfile to keep the checkout clean for later preflight checks.
|
||||
git -C "${goose_repo}" checkout -- Cargo.lock
|
||||
fi
|
||||
|
||||
if [[ ! -x "${bin_path}" ]]; then
|
||||
echo "Expected goose binary at ${bin_path}, but it was not built successfully." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
write_stamp "${branch}" "$(git -C "${goose_repo}" rev-parse HEAD)"
|
||||
|
||||
log "Local goose binary ready at ${bin_path}."
|
||||
if [[ "${print_bin}" == "1" ]]; then
|
||||
printf '%s\n' "${bin_path}"
|
||||
fi
|
||||
Executable
+197
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env swift
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// Generate a dev icon with worktree name badge
|
||||
// Usage: generate-dev-icon.swift <input-icns> <output-icns> <label>
|
||||
|
||||
guard CommandLine.arguments.count == 4 else {
|
||||
fputs("Usage: \(CommandLine.arguments[0]) <input-icns> <output-icns> <label>\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let inputPath = CommandLine.arguments[1]
|
||||
let outputPath = CommandLine.arguments[2]
|
||||
let label = CommandLine.arguments[3]
|
||||
|
||||
// Load the icns file
|
||||
guard let iconImage = NSImage(contentsOfFile: inputPath) else {
|
||||
fputs("Failed to load image: \(inputPath)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// Get the largest representation for best quality
|
||||
guard let rep = iconImage.representations.max(by: { $0.pixelsWide < $1.pixelsWide }) else {
|
||||
fputs("No image representations found\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let size = NSSize(width: rep.pixelsWide, height: rep.pixelsHigh)
|
||||
|
||||
// Create a new image with the badge
|
||||
let newImage = NSImage(size: size)
|
||||
newImage.lockFocus()
|
||||
|
||||
// Draw the original icon
|
||||
iconImage.draw(in: NSRect(origin: .zero, size: size))
|
||||
|
||||
// Configure badge
|
||||
let singleLineHeight = size.height * 0.22
|
||||
let padding = size.width * 0.03
|
||||
let maxBadgeWidth = size.width * 0.9
|
||||
|
||||
// Text attributes - calculate font size to fit
|
||||
let maxFontSize = singleLineHeight * 0.65
|
||||
var fontSize = maxFontSize
|
||||
var attributes: [NSAttributedString.Key: Any]
|
||||
|
||||
// Helper to wrap text on `-` characters
|
||||
func wrapText(_ text: String, maxWidth: CGFloat, attributes: [NSAttributedString.Key: Any]) -> [String] {
|
||||
let singleLineSize = (text as NSString).size(withAttributes: attributes)
|
||||
if singleLineSize.width <= maxWidth {
|
||||
return [text]
|
||||
}
|
||||
|
||||
// Split on `-` and try to form lines
|
||||
let parts = text.components(separatedBy: "-")
|
||||
if parts.count == 1 {
|
||||
return [text] // No `-` to wrap on
|
||||
}
|
||||
|
||||
var lines: [String] = []
|
||||
var currentLine = ""
|
||||
|
||||
for (index, part) in parts.enumerated() {
|
||||
let separator = index == 0 ? "" : "-"
|
||||
let testLine = currentLine.isEmpty ? part : currentLine + separator + part
|
||||
let testSize = (testLine as NSString).size(withAttributes: attributes)
|
||||
|
||||
if testSize.width <= maxWidth || currentLine.isEmpty {
|
||||
currentLine = testLine
|
||||
} else {
|
||||
lines.append(currentLine)
|
||||
currentLine = part
|
||||
}
|
||||
}
|
||||
if !currentLine.isEmpty {
|
||||
lines.append(currentLine)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// Find font size that fits (allowing up to 2 lines)
|
||||
var lines: [String] = []
|
||||
repeat {
|
||||
attributes = [
|
||||
.font: NSFont.systemFont(ofSize: fontSize, weight: .bold),
|
||||
.foregroundColor: NSColor.white
|
||||
]
|
||||
lines = wrapText(label, maxWidth: maxBadgeWidth - padding * 4, attributes: attributes)
|
||||
fontSize -= 1
|
||||
} while lines.count > 2 && fontSize > 8
|
||||
|
||||
// Calculate text dimensions using typographic metrics
|
||||
let lineHeight = (lines.first! as NSString).size(withAttributes: attributes).height
|
||||
let textHeight = lineHeight * CGFloat(lines.count)
|
||||
let maxLineWidth = lines.map { ($0 as NSString).size(withAttributes: attributes).width }.max() ?? 0
|
||||
|
||||
// Badge dimensions based on text
|
||||
let badgeHeight = textHeight + padding * 2
|
||||
let cornerRadius = badgeHeight * 0.2
|
||||
let badgeWidth = maxLineWidth + padding * 4
|
||||
let badgeX = (size.width - badgeWidth) / 2
|
||||
let badgeY = size.height - badgeHeight - padding - size.height * 0.05
|
||||
|
||||
// Draw badge background (light blue semi-transparent)
|
||||
let badgePath = NSBezierPath(roundedRect: NSRect(x: badgeX, y: badgeY, width: badgeWidth, height: badgeHeight),
|
||||
xRadius: cornerRadius, yRadius: cornerRadius)
|
||||
NSColor(calibratedRed: 0.4, green: 0.7, blue: 1.0, alpha: 0.85).setFill()
|
||||
badgePath.fill()
|
||||
|
||||
// Draw text centered in badge (multiple lines, bottom to top)
|
||||
for (index, line) in lines.reversed().enumerated() {
|
||||
let lineSize = (line as NSString).size(withAttributes: attributes)
|
||||
let textX = badgeX + (badgeWidth - lineSize.width) / 2
|
||||
let textY = badgeY + padding + lineHeight * CGFloat(index)
|
||||
(line as NSString).draw(at: NSPoint(x: textX, y: textY), withAttributes: attributes)
|
||||
}
|
||||
|
||||
newImage.unlockFocus()
|
||||
|
||||
// Convert to icns format
|
||||
// First, create PNG data at multiple sizes for icns
|
||||
guard let tiffData = newImage.tiffRepresentation,
|
||||
let bitmapRep = NSBitmapImageRep(data: tiffData) else {
|
||||
fputs("Failed to create bitmap representation\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// For simplicity, we'll create a PNG and then use iconutil if available,
|
||||
// or just save as PNG for the icon (Tauri can use PNG)
|
||||
guard let pngData = bitmapRep.representation(using: .png, properties: [:]) else {
|
||||
fputs("Failed to create PNG data\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// If output is .icns, we need to create an iconset and use iconutil
|
||||
if outputPath.hasSuffix(".icns") {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let iconsetPath = tempDir.appendingPathComponent("goose-dev.iconset")
|
||||
|
||||
// Remove existing iconset if present
|
||||
try? FileManager.default.removeItem(at: iconsetPath)
|
||||
try! FileManager.default.createDirectory(at: iconsetPath, withIntermediateDirectories: true)
|
||||
|
||||
// Generate all required sizes for iconset
|
||||
let sizes: [(name: String, size: Int)] = [
|
||||
("icon_16x16", 16),
|
||||
("icon_16x16@2x", 32),
|
||||
("icon_32x32", 32),
|
||||
("icon_32x32@2x", 64),
|
||||
("icon_128x128", 128),
|
||||
("icon_128x128@2x", 256),
|
||||
("icon_256x256", 256),
|
||||
("icon_256x256@2x", 512),
|
||||
("icon_512x512", 512),
|
||||
("icon_512x512@2x", 1024)
|
||||
]
|
||||
|
||||
for (name, targetSize) in sizes {
|
||||
let resizedImage = NSImage(size: NSSize(width: targetSize, height: targetSize))
|
||||
resizedImage.lockFocus()
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
newImage.draw(in: NSRect(x: 0, y: 0, width: targetSize, height: targetSize))
|
||||
resizedImage.unlockFocus()
|
||||
|
||||
guard let resizedTiff = resizedImage.tiffRepresentation,
|
||||
let resizedBitmap = NSBitmapImageRep(data: resizedTiff),
|
||||
let resizedPng = resizedBitmap.representation(using: .png, properties: [:]) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let filePath = iconsetPath.appendingPathComponent("\(name).png")
|
||||
try! resizedPng.write(to: filePath)
|
||||
}
|
||||
|
||||
// Use iconutil to create icns
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
|
||||
process.arguments = ["-c", "icns", iconsetPath.path, "-o", outputPath]
|
||||
try! process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
// Cleanup
|
||||
try? FileManager.default.removeItem(at: iconsetPath)
|
||||
|
||||
if process.terminationStatus != 0 {
|
||||
fputs("iconutil failed\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
} else {
|
||||
// Just save as PNG
|
||||
try! pngData.write(to: URL(fileURLWithPath: outputPath))
|
||||
}
|
||||
|
||||
print("Generated: \(outputPath)")
|
||||
Reference in New Issue
Block a user