chore(typo): retire tous les tirets cadratins/demi-cadratins + garde CI
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts). Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// Journal d'audit : trace persistante des opérations sensibles (création/révocation de tokens,
|
||||
// changements de réglages, génération de secrets, abonnements push, CRUD groupes). Exigence de
|
||||
// conformité entreprise (GDPR/SOX/ISO 27001). Règle ABSOLUE : ne JAMAIS journaliser un secret en
|
||||
// clair — `details` ne contient que des métadonnées non sensibles (ids, labels, compteurs).
|
||||
// clair : `details` ne contient que des métadonnées non sensibles (ids, labels, compteurs).
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AuditLogEntry } from '@arboretum/shared';
|
||||
import type { Db } from '../db/index.js';
|
||||
|
||||
@@ -49,7 +49,7 @@ function isExecutable(path: string): boolean {
|
||||
|
||||
/**
|
||||
* Résout le binaire `claude`. Si `configuredPath` est fourni (réglage UI), il est utilisé tel quel
|
||||
* — validé exécutable, message clair sinon — et JAMAIS mis en cache (modifiable à chaud). Sinon :
|
||||
* (validé exécutable, message clair sinon) et JAMAIS mis en cache (modifiable à chaud). Sinon :
|
||||
* `which claude`, mis en cache. Un service systemd/launchd démarre avec un PATH minimal sans
|
||||
* ~/.local/bin → `which claude` y échoue ; d'où le réglage de chemin explicite (et le PATH figé par
|
||||
* `arboretum install`).
|
||||
@@ -93,7 +93,7 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
||||
}
|
||||
const args: string[] = [];
|
||||
if (opts.resume) {
|
||||
// `--resume` doit toujours s'exécuter dans le cwd d'origine (garanti par l'appelant — spike S1).
|
||||
// `--resume` doit toujours s'exécuter dans le cwd d'origine (garanti par l'appelant, spike S1).
|
||||
args.push('--resume', opts.resume.claudeSessionId);
|
||||
if (opts.resume.fork) args.push('--fork-session');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Typage de dialogue à partir de l'écran reconstruit (ScreenReader) — fonctions PURES.
|
||||
// Typage de dialogue à partir de l'écran reconstruit (ScreenReader) : fonctions PURES.
|
||||
// L'écran sert à TYPER le dialogue et extraire ses options ; l'état (waiting vrai/faux) vient du
|
||||
// registre (source primaire). Tolérant aux variations de rendu inter-versions (texte aplati + regex).
|
||||
import type { DialogKind, DialogOption } from '@arboretum/shared';
|
||||
@@ -23,7 +23,7 @@ export function parseOptions(lines: string[]): DialogOption[] {
|
||||
/**
|
||||
* Retourne le dialogue typé visible à l'écran, ou null si aucun. `trust` et `question` priment sur
|
||||
* `permission` (un écran AskUserQuestion contient aussi « Esc to cancel »). Un écran numéroté non
|
||||
* typé est traité en `permission` générique (best-effort — le fallback reste le terminal web).
|
||||
* typé est traité en `permission` générique (best-effort, le fallback reste le terminal web).
|
||||
*/
|
||||
export function classifyDialog(lines: string[]): ClassifiedDialog | null {
|
||||
const text = lines.join('\n');
|
||||
|
||||
@@ -64,13 +64,13 @@ export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/** Session découverte (avec son cwd d'origine lu sur disque) — pour resume/fork. null si absente. */
|
||||
/** Session découverte (avec son cwd d'origine lu sur disque) : pour resume/fork. null si absente. */
|
||||
getDiscovered(claudeSessionId: string): DiscoveredJsonl | null {
|
||||
return this.byId.get(claudeSessionId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vivacité FRAÎCHE d'une session (relit le registre, ne se fie pas au cache) — garde-fou
|
||||
* Vivacité FRAÎCHE d'une session (relit le registre, ne se fie pas au cache) : garde-fou
|
||||
* anti-corruption : la route /resume doit refuser une session devenue vivante depuis le dernier scan.
|
||||
*/
|
||||
isClaudeSessionLive(claudeSessionId: string): boolean {
|
||||
|
||||
@@ -23,7 +23,7 @@ interface WatchEntry {
|
||||
refCount: number;
|
||||
/** nombre de sessions vivantes épinglant ce worktree. */
|
||||
sessionPins: number;
|
||||
/** épingle « permanente » (checkout principal d'un repo enregistré) — jamais évincée (P11). */
|
||||
/** épingle « permanente » (checkout principal d'un repo enregistré) : jamais évincée (P11). */
|
||||
repoPins: number;
|
||||
lastUsed: number;
|
||||
debounce: NodeJS.Timeout | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Clients des services git distants (Gitea / GitLab / GitHub) — P12. Uniquement `fetch` global
|
||||
// Clients des services git distants (Gitea / GitLab / GitHub) : P12. Uniquement `fetch` global
|
||||
// (Node ≥ 22), AUCUNE dépendance (pas d'octokit/gitbeaker). Chaque client expose verify() (test de
|
||||
// connectivité/auth) et listRepos() (paginé). Erreurs typées : AUTH_FAILED / RATE_LIMITED / UNREACHABLE.
|
||||
import type { GitAuthType, GitService, RemoteRepoSummary } from '@arboretum/shared';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Gestion des credentials des services git distants (P12). Les secrets (PAT/app password) sont
|
||||
// chiffrés par SecretBox AVANT insertion et ne ressortent JAMAIS via l'API (résumés sans secret).
|
||||
// getSecret()/authFor() sont INTERNES (clone, listRepos, test) — jamais routés.
|
||||
// getSecret()/authFor() sont INTERNES (clone, listRepos, test) : jamais routés.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
CreateGitCredentialRequest,
|
||||
@@ -137,7 +137,7 @@ export class GitCredentialsManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Secret déchiffré — INTERNE (clone/listRepos/test). Jamais exposé par une route. */
|
||||
/** Secret déchiffré : INTERNE (clone/listRepos/test). Jamais exposé par une route. */
|
||||
getSecret(id: string): string | null {
|
||||
const row = this.getRow(id);
|
||||
if (!row?.secret_encrypted) return null;
|
||||
|
||||
@@ -199,7 +199,7 @@ export async function branchExists(repoPath: string, branch: string): Promise<{
|
||||
return { local, remote };
|
||||
}
|
||||
|
||||
/** Branches locales + suivies de `origin` (noms courts) + branche par défaut — pour un sélecteur de base. */
|
||||
/** Branches locales + suivies de `origin` (noms courts) + branche par défaut : pour un sélecteur de base. */
|
||||
export async function listBranches(repoPath: string): Promise<{ local: string[]; remote: string[]; default: string | null }> {
|
||||
const local: string[] = [];
|
||||
const remote: string[] = [];
|
||||
@@ -476,7 +476,7 @@ export async function fetchRemote(worktreePath: string): Promise<void> {
|
||||
await git(worktreePath, ['fetch', '--all', '--prune'], GIT_PUSH_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/** `git pull` — `ff-only` par défaut (jamais de merge surprise) ; `rebase` optionnel. */
|
||||
/** `git pull` : `ff-only` par défaut (jamais de merge surprise) ; `rebase` optionnel. */
|
||||
export async function pull(worktreePath: string, mode: 'ff-only' | 'rebase' = 'ff-only'): Promise<void> {
|
||||
const args = mode === 'rebase' ? ['-c', 'rebase.autoStash=false', 'pull', '--rebase'] : ['pull', '--ff-only'];
|
||||
await git(worktreePath, args, GIT_PUSH_TIMEOUT_MS);
|
||||
@@ -532,7 +532,7 @@ async function resolveStartPoint(repoPath: string, baseRef?: string): Promise<st
|
||||
/**
|
||||
* Crée un worktree en résolvant la branche selon `mode` (voir `WorktreeBranchMode`). Renvoie l'action
|
||||
* effective. En mode `auto`, on choisit checkout / suivi-remote / création selon l'existence réelle de
|
||||
* la branche — indispensable pour les groupes hétérogènes (branche présente dans certains dépôts seulement).
|
||||
* la branche : indispensable pour les groupes hétérogènes (branche présente dans certains dépôts seulement).
|
||||
*/
|
||||
export async function addWorktree(
|
||||
repoPath: string,
|
||||
@@ -583,7 +583,7 @@ export async function push(repoPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée/bascule une branche dans le checkout (worktree) en `repoPath` — utilisé pour démarrer une
|
||||
* Crée/bascule une branche dans le checkout (worktree) en `repoPath` : utilisé pour démarrer une
|
||||
* session sur la branche principale sans worktree dédié. `create` → `git switch -c <branch>` (échoue
|
||||
* si la branche existe) ; sinon `git switch <branch>` (branche existante). Pas de `--` : l'argument
|
||||
* est une réf (pas un pathspec) et le nom est déjà filtré en amont par `isValidBranchName` (anti-flag).
|
||||
@@ -610,13 +610,13 @@ const GIT_CLONE_TIMEOUT_MS = 10 * 60_000; // 10 min : un clone réseau peut êtr
|
||||
|
||||
export interface CloneProgress {
|
||||
phase: string;
|
||||
/** pourcentage 0–100 si git le rapporte, sinon null. */
|
||||
/** pourcentage 0-100 si git le rapporte, sinon null. */
|
||||
percent: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone un dépôt via `git clone --progress` (P12). `spawn` (et non execFile) pour streamer la
|
||||
* progression depuis stderr. `env` éphémère (cf. withGitAuth) porte l'auth — JAMAIS dans l'URL.
|
||||
* progression depuis stderr. `env` éphémère (cf. withGitAuth) porte l'auth : JAMAIS dans l'URL.
|
||||
* `--` sépare l'URL/dest des options. L'appelant valide `dest` (sous scanRoots, non existant).
|
||||
*/
|
||||
export function cloneRepo(opts: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Gestion des groupes de travail (P5) : un groupe = collection nommée de repos (many-to-many).
|
||||
// Membership légère et persistée ; les worktrees/sessions du groupe ne sont PAS stockés ici —
|
||||
// Membership légère et persistée ; les worktrees/sessions du groupe ne sont PAS stockés ici :
|
||||
// ils restent servis par WorktreeManager/PtyManager et filtrés côté client par repoId.
|
||||
// Tout est synchrone : aucune I/O git/fs, node:sqlite est synchrone.
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { parse, resolve, sep } from 'node:path';
|
||||
|
||||
/**
|
||||
* Plus long ancêtre commun d'un ensemble de chemins absolus (par segments, jamais par préfixe
|
||||
* de chaîne — `/a/bc` n'est PAS un ancêtre de `/a/bcd`). Pour un seul chemin, renvoie ce chemin.
|
||||
* de chaîne : `/a/bc` n'est PAS un ancêtre de `/a/bcd`). Pour un seul chemin, renvoie ce chemin.
|
||||
*/
|
||||
export function commonAncestorDir(paths: string[]): string {
|
||||
const first = paths[0];
|
||||
|
||||
@@ -10,7 +10,7 @@ const HEAD_BYTES = 256 * 1024;
|
||||
const TAIL_BYTES = 64 * 1024;
|
||||
const TITLE_MAX = 120;
|
||||
|
||||
/** Reproduit le nom de dossier ~/.claude/projects à partir d'un cwd (validé 100 % — spike S4). */
|
||||
/** Reproduit le nom de dossier ~/.claude/projects à partir d'un cwd (validé 100 %, spike S4). */
|
||||
export function munge(cwd: string): string {
|
||||
return cwd.replace(/[^A-Za-z0-9]/g, '-');
|
||||
}
|
||||
@@ -82,7 +82,7 @@ function extractMeta(objs: Array<Record<string, unknown>>, meta: Meta): void {
|
||||
setOnce('cwd', o.cwd);
|
||||
setOnce('gitBranch', o.gitBranch);
|
||||
setOnce('version', o.version);
|
||||
// Titre : on retient la dernière valeur vue (la plus récente) — head puis tail → la queue gagne.
|
||||
// Titre : on retient la dernière valeur vue (la plus récente) : head puis tail → la queue gagne.
|
||||
const ai = asString(o.aiTitle);
|
||||
if (ai) meta.aiTitle = ai;
|
||||
const sum = asString(o.summary);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Création d'un nouveau projet : un dossier `<root>/<name>` créé sous une racine existante, dans
|
||||
// lequel on lance ensuite une session. Couche PURE (testable sans fs) — la création réelle du
|
||||
// lequel on lance ensuite une session. Couche PURE (testable sans fs) : la création réelle du
|
||||
// dossier, le `git init` et le spawn vivent dans `routes/projects.ts`.
|
||||
import { join } from 'node:path';
|
||||
import { isSafeAbsolutePath } from './git.js';
|
||||
|
||||
/**
|
||||
* Valide un nom de projet : UN SEUL segment de dossier. Anti-traversal de base — refuse les noms
|
||||
* Valide un nom de projet : UN SEUL segment de dossier. Anti-traversal de base : refuse les noms
|
||||
* vides, trop longs, `.`/`..`, et tout caractère de séparation (`/`, `\`) ou NUL. La résolution du
|
||||
* chemin complet (et sa re-validation) est faite par {@link resolveProjectDir}.
|
||||
*/
|
||||
|
||||
@@ -129,7 +129,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
throw Object.assign(new Error(`Not a directory: ${dir}`), { statusCode: 400 });
|
||||
}
|
||||
}
|
||||
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1).
|
||||
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant, S1).
|
||||
const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
|
||||
// Override de chemin du binaire claude (réglage UI) lu à chaque spawn → effet sans redémarrage.
|
||||
const claudeBinPath = command === 'claude' ? readClaudeBinPath(this.db) : null;
|
||||
@@ -197,7 +197,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Résout le claudeSessionId du CLI en pollant le registre par pid, puis le persiste (waitReady — S1). */
|
||||
/** Résout le claudeSessionId du CLI en pollant le registre par pid, puis le persiste (waitReady, S1). */
|
||||
private captureClaudeSessionId(s: ManagedSession): void {
|
||||
const deadline = Date.now() + CLAUDE_ID_TIMEOUT_MS;
|
||||
const tick = (): void => {
|
||||
@@ -307,7 +307,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ré-émet un `session_update` pour une session historique (P10) — utilisé au dés-archivage pour
|
||||
* Ré-émet un `session_update` pour une session historique (P10) : utilisé au dés-archivage pour
|
||||
* que tous les clients rafraîchissent le row (le champ `archived` repasse à false). No-op si la
|
||||
* session est vivante (déjà couverte par le flux live) ou inconnue.
|
||||
*/
|
||||
@@ -345,7 +345,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Arrêt du daemon : SIGTERM à toutes les sessions (le CLI nettoie son registre sur SIGTERM — spike S1). */
|
||||
/** Arrêt du daemon : SIGTERM à toutes les sessions (le CLI nettoie son registre sur SIGTERM, spike S1). */
|
||||
shutdown(): void {
|
||||
for (const id of this.live.keys()) this.kill(id);
|
||||
}
|
||||
@@ -396,7 +396,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
/**
|
||||
* Répond à un dialogue Claude sans clavier (P4-A) : traduit une intention de haut
|
||||
* niveau en keystrokes PTY, validée contre l'état fin du tracker (P3-B).
|
||||
* - 'select' N : positionne le curseur sur l'option N puis confirme (`"N\r"`) — protocole acté spike S3.
|
||||
* - 'select' N : positionne le curseur sur l'option N puis confirme (`"N\r"`) : protocole acté spike S3.
|
||||
* - 'confirm' : valide l'option pré-sélectionnée (`"\r"`).
|
||||
* - 'deny' : refus universel (Esc).
|
||||
* Réutilise le chemin write (mono-utilisateur : tout interactif peut répondre, observers non).
|
||||
@@ -417,7 +417,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
s.proc.write(`${optionN}\r`);
|
||||
return 'ok';
|
||||
}
|
||||
// confirm/deny n'exigent qu'un état d'attente (le dialogue Trust précède le registre — S1).
|
||||
// confirm/deny n'exigent qu'un état d'attente (le dialogue Trust précède le registre, S1).
|
||||
if (act?.activity !== 'waiting') return 'invalid';
|
||||
s.proc.write(action === 'deny' ? '\x1b' : '\r');
|
||||
return 'ok';
|
||||
@@ -446,10 +446,10 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
// ---- interne ----
|
||||
|
||||
/**
|
||||
* Push P4-B : notifie sur le FRONT MONTANT vers un état qui requiert l'attention — `waiting`
|
||||
* Push P4-B : notifie sur le FRONT MONTANT vers un état qui requiert l'attention : `waiting`
|
||||
* (un dialogue bloque) ou `idle` atteint depuis `busy` (Claude a terminé sa réponse, la session
|
||||
* redevient disponible). Le tracker réémet souvent le même état → on ne réagit qu'au changement.
|
||||
* Debounce annulable : un état ultra-bref (Claude repart tout seul) ne déclenche pas de notif —
|
||||
* Debounce annulable : un état ultra-bref (Claude repart tout seul) ne déclenche pas de notif :
|
||||
* à l'échéance on revérifie l'état réel. Cible tous les abonnements (un seul utilisateur).
|
||||
*/
|
||||
private maybeNotify(s: ManagedSession, next: SessionActivity | null): void {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { SecretBox } from './secret-box.js';
|
||||
import { recordAudit } from './audit-log.js';
|
||||
|
||||
// web-push est publié en CommonJS : on le charge via require (verbatimModuleSyntax + NodeNext),
|
||||
// typé par l'import type — même pattern que @xterm/headless dans screen-reader.ts.
|
||||
// typé par l'import type, même pattern que @xterm/headless dans screen-reader.ts.
|
||||
const require = createRequire(import.meta.url);
|
||||
const webpush = require('web-push') as typeof import('web-push');
|
||||
|
||||
@@ -33,7 +33,7 @@ interface SubRow {
|
||||
auth: string;
|
||||
}
|
||||
|
||||
/** Envoi d'une notif à un abonnement — injectable pour les tests ; défaut = web-push réel. */
|
||||
/** Envoi d'une notif à un abonnement : injectable pour les tests ; défaut = web-push réel. */
|
||||
export type PushSender = (
|
||||
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
|
||||
payload: string,
|
||||
@@ -70,7 +70,7 @@ export class PushService {
|
||||
this.vapidPrivate = priv;
|
||||
}
|
||||
|
||||
/** Clé publique VAPID — sûre à exposer (applicationServerKey côté navigateur). */
|
||||
/** Clé publique VAPID : sûre à exposer (applicationServerKey côté navigateur). */
|
||||
publicKey(): string {
|
||||
return this.vapidPublic;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Planificateur de la découverte auto des repos : scan au démarrage + re-scan périodique.
|
||||
// Calqué sur DiscoveryService (sessions) — start()/stop() avec timer .unref(). Démarré depuis
|
||||
// Calqué sur DiscoveryService (sessions) : start()/stop() avec timer .unref(). Démarré depuis
|
||||
// runDaemon() UNIQUEMENT (jamais buildApp), ce qui isole naturellement les tests vitest du scan.
|
||||
// Lui-même sans état : il lit les racines/l'intervalle dans `settings` et délègue à WorktreeManager.
|
||||
import type { Db } from '../db/index.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Découverte auto des dépôts git : marche bornée du système de fichiers à la recherche de `.git`.
|
||||
// Fonction PURE et tolérante (ne lève jamais) — testable isolément comme parseWorktreePorcelain.
|
||||
// Fonction PURE et tolérante (ne lève jamais) : testable isolément comme parseWorktreePorcelain.
|
||||
// N'appelle JAMAIS git (détection par présence de `.git`) : la validation réelle (isRepo) et la
|
||||
// résolution de default_branch se font paresseusement à l'enregistrement, pas par dépôt scanné.
|
||||
import { readdir } from 'node:fs/promises';
|
||||
@@ -39,7 +39,7 @@ interface Frame {
|
||||
* - un dossier contenant `.git` (fichier OU dossier → couvre les worktrees liés) est un repo :
|
||||
* on l'enregistre ; en profondeur on NE descend PAS dedans (sous-modules/worktrees imbriqués
|
||||
* ignorés). EXCEPTION : une racine fournie (depth 0) qui est elle-même un repo est aussi un
|
||||
* conteneur — on l'enregistre ET on continue de descendre pour trouver les repos internes ;
|
||||
* conteneur : on l'enregistre ET on continue de descendre pour trouver les repos internes ;
|
||||
* - on n'empile que les vrais sous-dossiers (`d.isDirectory()`), donc les symlinks ne sont PAS
|
||||
* suivis (anti-cycle + anti-sortie de racine), et on saute dotdirs + excludeDirs ;
|
||||
* - bornes : `maxDepth`, `maxRepos`, et un éventuel `signal` (timeout global) ;
|
||||
|
||||
@@ -50,7 +50,7 @@ export function normalizeScanRoots(raw: unknown): string[] | null {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Valide un intervalle (entier 0–MAX_SCAN_INTERVAL_MIN). Retourne null si invalide. */
|
||||
/** Valide un intervalle (entier 0-MAX_SCAN_INTERVAL_MIN). Retourne null si invalide. */
|
||||
export function normalizeScanIntervalMin(raw: unknown): number | null {
|
||||
if (typeof raw !== 'number' || !Number.isInteger(raw)) return null;
|
||||
if (raw < 0 || raw > MAX_SCAN_INTERVAL_MIN) return null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Reconstruction d'écran via @xterm/headless : terminal headless PERSISTANT par session, alimenté
|
||||
// incrémentalement par le flux PTY. Remplace le strip ANSI naïf (qui « mange les espaces » et casse
|
||||
// la détection des dialogues — verdict S1/S3). Aucune dépendance DOM (usage Node).
|
||||
// la détection des dialogues, verdict S1/S3). Aucune dépendance DOM (usage Node).
|
||||
import { createRequire } from 'node:module';
|
||||
import type { Terminal as XtermTerminal } from '@xterm/headless';
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ export class SessionArchiveService extends EventEmitter<SessionArchiveEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Diffuse l'archivage d'une session (déjà archivée + auditée par l'appelant — ex. route manuelle). */
|
||||
/** Diffuse l'archivage d'une session (déjà archivée + auditée par l'appelant, ex. route manuelle). */
|
||||
emitArchived(id: string, archivedAt: string): void {
|
||||
this.emit('session_archived', { id, archivedAt });
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export interface WorktreeManagerEvents {
|
||||
repo_removed: [string];
|
||||
worktree_update: [{ repoId: string; worktree: WorktreeSummary }];
|
||||
worktree_removed: [{ repoId: string; path: string }];
|
||||
/** P7 — le détail (liste des changements/diff) d'un worktree regardé a changé ; relayé en push
|
||||
/** P7 : le détail (liste des changements/diff) d'un worktree regardé a changé ; relayé en push
|
||||
* ciblé `worktree_changes` aux seules connexions ayant `watch`é cette clé. */
|
||||
worktree_changes: [{ repoId: string; path: string }];
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
private readonly fsWatcher?: FsWatcherService,
|
||||
) {
|
||||
super();
|
||||
// P7 — un changement FS sur un worktree regardé invalide le cache, rediffuse le status frais
|
||||
// P7 : un changement FS sur un worktree regardé invalide le cache, rediffuse le status frais
|
||||
// (worktree_update : compteurs légers pour tout le dashboard) et signale aux clients qui le
|
||||
// regardent de re-fetcher le détail (worktree_changes ciblé). On résout repoId → row à la volée.
|
||||
this.fsWatcher?.on('worktree_fs_change', ({ repoId, path }) => {
|
||||
@@ -207,7 +207,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
}
|
||||
|
||||
/**
|
||||
* P11 — arme un watcher PERMANENT sur le checkout principal de chaque repo visible : un
|
||||
* P11 : arme un watcher PERMANENT sur le checkout principal de chaque repo visible : un
|
||||
* `git checkout`/`switch` en CLI sur le principal est ainsi rediffusé en temps réel sans qu'un
|
||||
* client ne l'ait « regardé ». Appelé depuis runDaemon (jamais buildApp → pas d'effet en tests purs).
|
||||
*/
|
||||
@@ -252,7 +252,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
|
||||
/**
|
||||
* Découvre les repos git sous `roots` et auto-enregistre les NOUVEAUX (path absent de la DB).
|
||||
* Idempotent et anti-résurrection : un path déjà présent — visible OU masqué — n'est jamais
|
||||
* Idempotent et anti-résurrection : un path déjà présent (visible OU masqué) n'est jamais
|
||||
* réécrit (INSERT ... ON CONFLICT DO NOTHING). Les scans concurrents sont coalescés. Tolérant :
|
||||
* ne lève pas (le scanner avale les erreurs FS). N'appelle aucun git pendant le scan
|
||||
* (default_branch=NULL, résolu paresseusement par rowToSummary à l'affichage).
|
||||
@@ -302,7 +302,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
|
||||
/**
|
||||
* Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree.
|
||||
* Les sessions explicitement masquées (`hidden`) sont exclues — cohérent avec `/api/v1/sessions`
|
||||
* Les sessions explicitement masquées (`hidden`) sont exclues, cohérent avec `/api/v1/sessions`
|
||||
* (sans quoi le masquage était ignoré dans les fiches worktree). Le tri managées/externes est laissé
|
||||
* au client (interrupteur « afficher les externes »), qui dispose du champ `source`. La garde de
|
||||
* suppression réclame en revanche TOUTES les sessions vivantes (`includeHidden`) pour rester sûre.
|
||||
@@ -351,7 +351,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
// quand l'utilisateur active « afficher les masqués ».
|
||||
const rows = this.db.prepare('SELECT id FROM repos WHERE hidden = 0 ORDER BY created_at ASC').all() as Array<{ id: string }>;
|
||||
// Tolérance par repo : avec la découverte auto, un repo douteux (git en échec, chemin disparu,
|
||||
// permission) ne doit JAMAIS faire planter tout l'endpoint — il ne contribue alors aucun worktree.
|
||||
// permission) ne doit JAMAIS faire planter tout l'endpoint, il ne contribue alors aucun worktree.
|
||||
const lists = await Promise.all(rows.map((r) => this.listRepoWorktrees(r.id).catch(() => [])));
|
||||
return lists.flat();
|
||||
}
|
||||
@@ -420,7 +420,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Branches locales/remote + branche par défaut d'un repo — alimente le sélecteur de base côté UI. */
|
||||
/** Branches locales/remote + branche par défaut d'un repo : alimente le sélecteur de base côté UI. */
|
||||
async listRepoBranches(repoId: string): Promise<{ local: string[]; remote: string[]; default: string | null }> {
|
||||
const row = this.getRepoRow(repoId);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||
@@ -444,16 +444,16 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
const mode = opts.mode ?? 'all';
|
||||
return this.withLock(repoId, async () => {
|
||||
if (opts.amend && !(await isUnpushed(w.path))) {
|
||||
throw httpError(409, 'ALREADY_PUSHED', 'Last commit is already pushed — amend would rewrite shared history');
|
||||
throw httpError(409, 'ALREADY_PUSHED', 'Last commit is already pushed: amend would rewrite shared history');
|
||||
}
|
||||
const st = await worktreeStatus(w.path);
|
||||
// Garde-fou « rien à committer » (sauf amend, qui peut ne changer que le message).
|
||||
if (!opts.amend) {
|
||||
if (mode === 'all' && st.dirtyCount === 0) {
|
||||
throw httpError(409, 'NOTHING_TO_COMMIT', 'Nothing to commit — working tree is clean');
|
||||
throw httpError(409, 'NOTHING_TO_COMMIT', 'Nothing to commit: working tree is clean');
|
||||
}
|
||||
if (mode === 'staged' && (st.stagedCount ?? 0) === 0) {
|
||||
throw httpError(409, 'NOTHING_STAGED', 'Nothing staged — stage files first');
|
||||
throw httpError(409, 'NOTHING_STAGED', 'Nothing staged: stage files first');
|
||||
}
|
||||
}
|
||||
try {
|
||||
@@ -646,17 +646,17 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
return this.withLock(repoId, async () => {
|
||||
if (!force) {
|
||||
if ((await worktreeStatus(w.path)).dirtyCount > 0) {
|
||||
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes — commit or pass force');
|
||||
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes: commit or pass force');
|
||||
}
|
||||
if ((await worktreeStatus(row.path)).dirtyCount > 0) {
|
||||
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes — commit/stash or pass force');
|
||||
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes: commit/stash or pass force');
|
||||
}
|
||||
}
|
||||
try {
|
||||
await removeWorktree(row.path, w.path, force);
|
||||
} catch (err) {
|
||||
if (!force && isDirtyWorktreeError(err)) {
|
||||
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes — pass force to promote anyway');
|
||||
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes: pass force to promote anyway');
|
||||
}
|
||||
throw httpError(500, 'WORKTREE_REMOVE_FAILED', (err as Error).message);
|
||||
}
|
||||
@@ -672,7 +672,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance UNE session dans le checkout principal du repo (`repo.path`) — pour « bosser sur la branche
|
||||
* Lance UNE session dans le checkout principal du repo (`repo.path`) : pour « bosser sur la branche
|
||||
* principale » sans créer de worktree. Si `branch` est fourni, crée/bascule d'abord cette branche
|
||||
* dans ce checkout (`git switch[-c]`), refusé si l'arbre est sale (on n'écrase pas un HEAD modifié).
|
||||
* Volontairement SANS hooks ni pré-trust (contraste avec createWorktree) : le checkout principal
|
||||
@@ -691,7 +691,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
if (branch !== undefined) {
|
||||
// garde-fou : ne pas basculer le HEAD du checkout principal s'il a des changements non sauvegardés.
|
||||
if ((await worktreeStatus(row.path)).dirtyCount > 0) {
|
||||
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes — commit or stash before switching branch');
|
||||
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes: commit or stash before switching branch');
|
||||
}
|
||||
try {
|
||||
await switchBranch(row.path, { branch, create: req.newBranch ?? true });
|
||||
@@ -731,14 +731,14 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'Cannot remove the main worktree');
|
||||
// garde-fou : une session vivante tourne dans ce worktree → exiger une confirmation explicite.
|
||||
if (!force && this.sessionsForCwd(w.path, { includeHidden: true }).some((s) => s.live)) {
|
||||
throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree — pass force to delete anyway');
|
||||
throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree: pass force to delete anyway');
|
||||
}
|
||||
return this.withLock(repoId, async () => {
|
||||
try {
|
||||
await removeWorktree(row.path, w.path, force);
|
||||
} catch (err) {
|
||||
if (!force && isDirtyWorktreeError(err)) {
|
||||
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes — pass force to delete anyway');
|
||||
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes: pass force to delete anyway');
|
||||
}
|
||||
throw httpError(500, 'WORKTREE_REMOVE_FAILED', (err as Error).message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user