gutterpress 0.10.0 → 0.10.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +3 -16
  2. package/dist/api/index.d.ts +10 -19
  3. package/dist/api/index.js +8 -64
  4. package/dist/{audit-cvarpa72.js → audit-7a37g51p.js} +5 -5
  5. package/dist/{build-6r502chw.js → build-kdaet3pd.js} +5 -5
  6. package/dist/checks/source/index.d.ts +1 -0
  7. package/dist/checks/source/merge-markers.d.ts +3 -0
  8. package/dist/{cli-zfcryxg8.js → cli-ak1tagkr.js} +94 -57
  9. package/dist/{cli-eq5naw4m.js → cli-df7gcyns.js} +370 -1249
  10. package/dist/{cli-149edp6b.js → cli-dx6sxj2t.js} +1 -1
  11. package/dist/{cli-cqtggsng.js → cli-mcb484g3.js} +2 -47
  12. package/dist/{cli-k1065rkg.js → cli-w692g6dx.js} +75 -29
  13. package/dist/cli.js +14 -15
  14. package/dist/{doctor-akvxbtjb.js → doctor-vajd5h45.js} +2 -2
  15. package/dist/{engine-b159tbns.js → engine-0nt35svz.js} +2 -2
  16. package/dist/{engine-ft4cr3ep.js → engine-gkcqdye5.js} +1 -1
  17. package/dist/{gutterpress-viewer-te8g5grx.js → gutterpress-viewer-tma1qwz2.js} +57 -15
  18. package/dist/{index-ge7q9xj3.js → index-6g72h2n1.js} +771 -1482
  19. package/dist/{index-ycpvr0am.js → index-b2rqw41a.js} +1 -1
  20. package/dist/{index-mdefp0y5.js → index-dtrftayr.js} +75 -27
  21. package/dist/{index-wq3r5pj7.js → index-kvgq4q3q.js} +94 -57
  22. package/dist/index.js +9 -65
  23. package/dist/lib/git-fs.d.ts +43 -0
  24. package/dist/lib/host-policy.d.ts +14 -2
  25. package/dist/lib/remote-auth/clone.d.ts +0 -21
  26. package/dist/lib/remote-auth/converge-merge.d.ts +24 -7
  27. package/dist/lib/remote-auth/generic-auth.d.ts +0 -22
  28. package/dist/lib/remote-auth/git-http.d.ts +1 -1
  29. package/dist/lib/remote-auth/github-auth.d.ts +2 -22
  30. package/dist/lib/remote-auth/operation-log.d.ts +4 -9
  31. package/dist/lib/remote-auth/sync-messages.d.ts +35 -12
  32. package/dist/lib/remote-auth/sync-types.d.ts +30 -101
  33. package/dist/lib/remote-auth/sync.d.ts +19 -21
  34. package/dist/lib/remote-auth/token-store.d.ts +0 -10
  35. package/dist/lib/remote-auth/transport.d.ts +42 -40
  36. package/dist/lib/source-provider.d.ts +12 -0
  37. package/dist/{lint-p2sw53d9.js → lint-p6a4bkvh.js} +5 -5
  38. package/dist/{new-hvq0x91q.js → new-4cdzfbsj.js} +5 -5
  39. package/dist/{plugin-pssmk0dx.js → plugin-a89tqspy.js} +5 -5
  40. package/dist/{preflight-4j00yd0g.js → preflight-r4y3f8f9.js} +5 -5
  41. package/dist/{preview-d4s6gk6p.js → preview-k8n2kew7.js} +5 -5
  42. package/dist/{publish-tkc26en7.js → publish-jnhjp2vd.js} +5 -5
  43. package/dist/{source-provider-3tcj6qg2.js → source-provider-2j5x2hyw.js} +1 -1
  44. package/dist/{source-provider-vanafrt9.js → source-provider-kn41jmbs.js} +1 -1
  45. package/dist/{validate-w9vfefrw.js → validate-prnybpkx.js} +5 -5
  46. package/package.json +1 -1
  47. package/dist/lib/app-heartbeat.d.ts +0 -102
  48. package/dist/lib/remote-auth/image-clash.d.ts +0 -17
  49. package/dist/lib/remote-auth/recovery/classify.d.ts +0 -97
  50. package/dist/lib/remote-auth/recovery/inspect.d.ts +0 -75
  51. package/dist/lib/remote-auth/recovery/locks.d.ts +0 -20
  52. package/dist/lib/remote-auth/recovery/repair.d.ts +0 -29
  53. package/dist/lib/remote-auth/recovery/types.d.ts +0 -39
  54. package/dist/repair-2va4w12t.js +0 -137
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `node:fs`, with crash-atomic writes for git's MUTABLE metadata.
3
+ *
4
+ * WHY THIS EXISTS. isomorphic-git writes `.git/index`, `HEAD`, `packed-refs`
5
+ * and loose refs with a plain `fs.writeFile` — an open-truncate-write. If the
6
+ * process dies between the truncate and the write (the writer force-quits the
7
+ * app, the OS kills it, the machine sleeps badly) the file is left EMPTY or
8
+ * half-written, and the repository can no longer be read. That is the one
9
+ * class of history damage Gutterpress itself causes; every other class needs
10
+ * an outside actor. Since isomorphic-git takes `fs` as a parameter, replacing
11
+ * the write with temp-file + `rename` removes the truncation window entirely
12
+ * — a reader sees either the old file or the new one, never a torn one.
13
+ *
14
+ * SCOPE. Only git's mutable metadata is redirected. Working-tree files and
15
+ * content-addressed objects keep the plain write: objects are written once
16
+ * under a hash of their contents (a torn object is a NEW file that simply
17
+ * fails to inflate, it never destroys a good one), and the working tree is
18
+ * the author's own files, which `rename` semantics would not improve.
19
+ *
20
+ * NOT A DURABILITY BARRIER. There is deliberately no `fsync`: this closes the
21
+ * PROCESS-DEATH window, where the page cache survives and `rename` is atomic
22
+ * to every reader. Surviving sudden power loss would mean fsync-ing the temp
23
+ * file and its directory on every ref write, which is a real cost on the
24
+ * snapshot hot path and guards a failure the app does not cause.
25
+ *
26
+ * Everything not named below passes through to `node:fs` unchanged, so
27
+ * `import { gitFs as fs }` is a drop-in for `import * as fs from "node:fs"`.
28
+ */
29
+ import * as nodeFs from "node:fs";
30
+ /** True when `file` is git metadata that must be replaced, never truncated. */
31
+ export declare function needsAtomicWrite(file: unknown): file is string;
32
+ /**
33
+ * `node:fs` with the two writers above swapped in — the object callers import
34
+ * as `fs` and hand to isomorphic-git. Everything else passes through
35
+ * untouched.
36
+ *
37
+ * WHY an object and not `export * from "node:fs"`: bun build with
38
+ * `--packages=external` compiles a star re-export of a BUILTIN into a
39
+ * `__reExport(exports, node_fs)` call whose `node_fs` binding is never
40
+ * emitted, so the built lib throws `ReferenceError: node_fs is not defined`
41
+ * on import. A plain spread has no such hazard.
42
+ */
43
+ export declare const gitFs: typeof nodeFs;
@@ -40,6 +40,17 @@ export interface AutoSyncPolicy {
40
40
  export declare const AUTO_SYNC_MIN_MINUTES = 1;
41
41
  export declare const AUTO_SYNC_MAX_MINUTES: number;
42
42
  export declare const AUTO_SYNC_DEFAULT_MINUTES = 2;
43
+ /**
44
+ * Minutes between PUSH-enabled auto-sync passes (owner decision 2026-08-23).
45
+ * Every ~2-minute tick still PULLS — a collaborator's work keeps arriving
46
+ * promptly — but only a tick whose push window has elapsed also pushes, and
47
+ * the desktop pushes once more on project close/app exit. This is what keeps
48
+ * an actively-typing author from minting a snapshot-and-push every 2 minutes
49
+ * (the "commit wall"): between push windows, ticks with an unmoved remote
50
+ * commit nothing at all. Deliberately a constant, NOT a settings knob — the
51
+ * sync cadence is hidden policy, same as the tick interval's default.
52
+ */
53
+ export declare const AUTO_SYNC_PUSH_INTERVAL_MINUTES = 15;
43
54
  /**
44
55
  * Resolve the debounce delay (ms) for the host's auto-snapshot timer, or
45
56
  * `null` when automatic snapshots are disabled. Pure — the testable core of
@@ -61,8 +72,9 @@ export declare function autoSnapshotDelayMs(policy: Partial<AutoSnapshotPolicy>
61
72
  * the default and is then clamped into [AUTO_SYNC_MIN_MINUTES,
62
73
  * AUTO_SYNC_MAX_MINUTES].
63
74
  *
64
- * Note: the host orchestrator ALSO debounces on file-change triggers; this
65
- * cadence governs only the periodic safety timer (§4.2 "Periodic safety").
75
+ * Note: this is the host orchestrator's ONLY sync trigger — the file-change
76
+ * debounce that used to sit beside it was removed (it could never fire before
77
+ * this interval already had).
66
78
  */
67
79
  export declare function autoSyncDelayMs(policy: Partial<AutoSyncPolicy> | undefined): number | null;
68
80
  /**
@@ -7,22 +7,6 @@ export interface CloneProgressEvent {
7
7
  loaded: number;
8
8
  total?: number;
9
9
  }
10
- /**
11
- * Provider provenance recorded next to a cloned project (ADR 0006 D4):
12
- * metadata for the repo picker / re-auth UX, never consulted by the
13
- * editing/preview/build paths.
14
- */
15
- export interface ProjectProvenance {
16
- provider: "github";
17
- owner: string;
18
- repo: string;
19
- /**
20
- * Legacy GitHub-App installation id. New clones never write it (the OAuth
21
- * App model has no installations — ADR 0006 D1 amendment 2026-06-10); kept
22
- * optional so provenance files written by 0.4.x betas still parse.
23
- */
24
- installationId?: string;
25
- }
26
10
  export interface CloneRepositoryOptions {
27
11
  /** HTTPS clone URL. Tokens embedded in the URL are stripped (D7). */
28
12
  url: string;
@@ -49,8 +33,6 @@ export interface CloneRepositoryOptions {
49
33
  onProgress?: (event: CloneProgressEvent) => void;
50
34
  /** When provided, credentials embedded in `url` are migrated into it (D7). */
51
35
  tokenStore?: TokenStore;
52
- /** Provider provenance to record beside the clone (ADR 0006 D4). */
53
- provenance?: ProjectProvenance;
54
36
  /**
55
37
  * Injectable git HTTP transport for tests (isomorphic-git's `http` client
56
38
  * shape). Defaults to isomorphic-git's node client.
@@ -63,9 +45,6 @@ export interface CloneRepositoryResult {
63
45
  /** The checked-out branch. */
64
46
  branch?: string;
65
47
  }
66
- export declare function provenancePath(projectDir: string): string;
67
- /** Read recorded provider provenance for a project, if any. Never throws. */
68
- export declare function readProjectProvenance(projectDir: string): Promise<ProjectProvenance | null>;
69
48
  /**
70
49
  * Reduce a (possibly renderer-/user-supplied) project folder name to a single
71
50
  * safe path segment: path separators become dashes and leading dots are
@@ -1,5 +1,23 @@
1
- import type { GitCache, ImageClash } from "./sync-types.ts";
2
- export type { ImageClash };
1
+ import { onlineSiblingPath, isOnlineSibling } from "./sync-messages.ts";
2
+ export { onlineSiblingPath, isOnlineSibling };
3
+ import type { GitCache, KeptBothFile } from "./sync-types.ts";
4
+ export type { KeptBothFile };
5
+ /** Type guard exposing MergeConflictError's per-file payload. */
6
+ export declare function isMergeConflictError(e: unknown): e is {
7
+ data: {
8
+ filepaths: string[];
9
+ bothModified: string[];
10
+ deleteByUs: string[];
11
+ deleteByTheirs: string[];
12
+ };
13
+ };
14
+ /**
15
+ * A non-forced `git.checkout` refused to overwrite working-tree files whose
16
+ * content moved after we committed them. isomorphic-git detects this in its
17
+ * analysis pass and throws BEFORE touching the tree, so the refusal is
18
+ * atomic: nothing on disk has been written.
19
+ */
20
+ export declare function isCheckoutConflict(e: unknown): boolean;
3
21
  export interface ConvergeResult {
4
22
  /** The branch tip after the merge (the merge commit, or the ff/no-op tip). */
5
23
  oid: string;
@@ -8,13 +26,13 @@ export interface ConvergeResult {
8
26
  * the writer should blend these. Empty when everything merged cleanly.
9
27
  */
10
28
  combinedFiles: string[];
11
- /** Clashing images (newer version kept) for the non-blocking picker. */
12
- imageClashes: ImageClash[];
29
+ /** Files kept as a pair: ours at `path`, theirs at `onlinePath`. */
30
+ keptBothFiles: KeptBothFile[];
13
31
  }
14
32
  /** Snapshot message for the equalization commit (driver-unreachable cases). */
15
33
  export declare const CONVERGE_PREPARE_MESSAGE = "Getting your changes ready to combine with the online version";
16
- /** Snapshot message for the post-merge restore commit (newer-wins/edits). */
17
- export declare const CONVERGE_RESTORE_MESSAGE = "Kept the newest version of files that can't be combined";
34
+ /** Snapshot message for the post-merge restore commit (kept-both/edits). */
35
+ export declare const CONVERGE_RESTORE_MESSAGE = "Kept both versions of the files that can't be combined";
18
36
  /** The merge commit message (same wording the old sync used). */
19
37
  export declare const CONVERGE_MERGE_MESSAGE = "Combined your changes with the online version";
20
38
  /**
@@ -44,5 +62,4 @@ export declare function convergeMerge(params: {
44
62
  };
45
63
  authorName?: string;
46
64
  authorEmail?: string;
47
- allowUnrelatedHistories?: boolean;
48
65
  }): Promise<ConvergeResult>;
@@ -15,7 +15,6 @@
15
15
  * SECURITY INVARIANT: token values never appear in error messages or logs.
16
16
  */
17
17
  import type httpNode from "isomorphic-git/http/node";
18
- import type { HostCallbacks, RemoteAuthProvider } from "./github-auth.ts";
19
18
  import { type HostCredential } from "./token-store.ts";
20
19
  /** What the Advanced Setup UI collects for "Connect a Git server". */
21
20
  export interface GenericTokenConnectInput {
@@ -38,14 +37,6 @@ export interface GenericTokenConnectInput {
38
37
  */
39
38
  repoUrl?: string;
40
39
  }
41
- /**
42
- * The {@link RemoteAuthProvider} `connect` input for the generic provider:
43
- * the UI-collected fields plus the standard host callbacks (`onUserCode` is
44
- * part of the shared contract but unused — there is no device code in the
45
- * token flow; pass a no-op, or use {@link connectGenericHost} directly).
46
- */
47
- export interface GenericHostCallbacks extends HostCallbacks, GenericTokenConnectInput {
48
- }
49
40
  export interface GenericAuthOptions {
50
41
  /** Injectable git HTTP transport for tests. */
51
42
  httpClient?: typeof httpNode;
@@ -82,16 +73,3 @@ export declare function knownForgeTokenUrl(host: string): string | null;
82
73
  * a full end-to-end verification of the token against a real repository.
83
74
  */
84
75
  export declare function connectGenericHost(input: GenericTokenConnectInput, options?: GenericAuthOptions): Promise<HostCredential>;
85
- /**
86
- * {@link RemoteAuthProvider} wrapper for the generic token flow, so host apps
87
- * can route any non-GitHub origin through the shared provider contract.
88
- */
89
- export declare class GenericTokenAuthProvider implements RemoteAuthProvider {
90
- private readonly options;
91
- constructor(options?: GenericAuthOptions);
92
- /** Handles any smart-HTTP(S) host that is not github.com (device flow). */
93
- matches(origin: URL): boolean;
94
- connect(callbacks: GenericHostCallbacks): Promise<HostCredential>;
95
- /** Revalidate a stored credential: false only on a definitive rejection. */
96
- validate(credential: HostCredential): Promise<boolean>;
97
- }
@@ -31,7 +31,7 @@
31
31
  *
32
32
  * One timer serves the whole request (re-armed via `refresh()`; no per-chunk
33
33
  * allocation). On timeout the thrown error's message classifies as offline in
34
- * recovery/classify.ts. Known limitation (documented, not fixable at this
34
+ * transport.ts. Known limitation (documented, not fixable at this
35
35
  * layer): isomorphic-git's client accepts no AbortSignal, so an abandoned
36
36
  * timed-out transfer's socket is left to the OS/agent to reap.
37
37
  *
@@ -17,19 +17,6 @@ export interface HostCallbacks {
17
17
  /** Optional cancellation (user closed the dialog). */
18
18
  signal?: AbortSignal;
19
19
  }
20
- /**
21
- * Per-host auth-acquisition plugin contract (ADR 0006 D3 layer 3). github.com
22
- * gets the device flow below; every other host gets the generic token flow
23
- * (#14, not in this change).
24
- */
25
- export interface RemoteAuthProvider {
26
- /** Does this provider handle the given remote host? */
27
- matches(origin: URL): boolean;
28
- /** Interactive flow producing a credential for the host. */
29
- connect(callbacks: HostCallbacks): Promise<HostCredential>;
30
- /** Cheap revalidation for stored credentials. */
31
- validate(credential: HostCredential): Promise<boolean>;
32
- }
33
20
  /**
34
21
  * Client id resolution: explicit option → env var → registered default.
35
22
  * Empty/whitespace-only values at any layer are treated as unset (the packaged
@@ -48,20 +35,13 @@ export interface GitHubAuthProviderOptions {
48
35
  /** Injectable sleep for tests (so polling tests run instantly). */
49
36
  sleepImpl?: (ms: number) => Promise<void>;
50
37
  }
51
- /** GitHub device-flow {@link RemoteAuthProvider} (ADR 0006 D1). */
52
- export declare class GitHubAuthProvider implements RemoteAuthProvider {
38
+ /** GitHub device-flow credential acquisition (ADR 0006 D1). */
39
+ export declare class GitHubAuthProvider {
53
40
  private readonly clientId;
54
41
  private readonly fetchImpl;
55
42
  private readonly sleep;
56
43
  constructor(options?: GitHubAuthProviderOptions);
57
- matches(origin: URL): boolean;
58
44
  connect(callbacks: HostCallbacks): Promise<HostCredential>;
59
- /**
60
- * Cheap credential revalidation against `GET /user`. Returns `false` on any
61
- * non-200 (401 revoked/expired, 403 forbidden/rate-limit-blocked, …);
62
- * network failures are treated as "can't tell" → `true`.
63
- */
64
- validate(credential: HostCredential): Promise<boolean>;
65
45
  /** Best-effort login lookup after auth — failure is non-fatal. */
66
46
  private fetchUsername;
67
47
  }
@@ -20,10 +20,10 @@
20
20
  * [ISO-timestamp] LEVEL operation: step=<step> key=value ... | <message>
21
21
  *
22
22
  * Example:
23
- * [2026-06-19T12:34:56.789Z] INFO recovery: kind=unrelated_histories repo=my-book branch=main | starting recovery
24
- * [2026-06-19T12:34:56.890Z] INFO recovery: step=backup | backup created at /tmp/print-sync-recovery/my-book/...zip
25
- * [2026-06-19T12:34:57.789Z] WARN recovery: step=merge | merge conflicted files=manifest.yaml,notes.md
26
- * [2026-06-19T12:34:57.890Z] INFO recovery: result=needs_user | surfaced 2 conflicted files to user
23
+ * [2026-06-19T12:34:56.789Z] INFO sync: repo=my-book branch=main | starting sync
24
+ * [2026-06-19T12:34:56.890Z] INFO sync: step=snapshot | committed 3 changed files
25
+ * [2026-06-19T12:34:57.789Z] WARN sync: step=merge | combined files=manifest.yaml,notes.md
26
+ * [2026-06-19T12:34:57.890Z] INFO sync: result=synced | pushed to the online copy
27
27
  *
28
28
  * Cross-platform: uses `node:fs` appendFileSync + `node:path`. The caller
29
29
  * is responsible for providing a valid directory (the logger creates the
@@ -59,8 +59,3 @@ export declare function createFileLogger(logFile: string, operation: string, min
59
59
  * don't pass `logFile` see zero behavior change and zero file I/O).
60
60
  */
61
61
  export declare function resolveLogger(logFile: string | undefined, operation: string): OperationLogger;
62
- /**
63
- * Shorten a 40-char OID to the first 7 characters for compact log lines.
64
- * Same convention as `git log --oneline`.
65
- */
66
- export declare function shortOid(oid: string): string;
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Author-language copy for sync / pull / push / conflict-resolution (never raw
3
- * git words). Extracted from sync.ts so the transport, conflict-resolution and
4
- * orchestrator modules share ONE source of these strings their exact wording
5
- * is part of the observable contract (the host renders them verbatim).
2
+ * Author-language copy for sync (never raw git words). Extracted from sync.ts
3
+ * so the transport and orchestrator modules share ONE source of these
4
+ * strings their exact wording is part of the observable contract (the host
5
+ * renders them verbatim).
6
6
  */
7
7
  export declare const MSG_UP_TO_DATE = "Everything is in sync.";
8
8
  export declare const MSG_UP_TO_DATE_PULLED = "Everything is in sync. The latest online changes were downloaded to this computer.";
@@ -12,14 +12,37 @@ export declare const MSG_OFFLINE = "Your changes are saved on this computer. gut
12
12
  export declare const MSG_AUTH = "The online repository didn't accept the saved connection. Reconnect and try again.";
13
13
  export declare const MSG_INSECURE_TRANSPORT = "This project's online address isn't secure, so the saved connection wasn't sent \u2014 connections are never sent over an insecure address. Switch the address to a secure one (starting with https), or to a local loopback address for a server on this computer, to sync.";
14
14
  export declare const MSG_BUSY = "The online copy is changing very quickly right now. Your work is saved on this computer \u2014 try Sync again in a moment.";
15
- export declare const MSG_UNRELATED = "The online address points at a different project's files, so the two can't be combined. Check the project's online address.";
15
+ /**
16
+ * The project's own version history is unreadable — a damaged or missing
17
+ * `.git`. Says the three things that are actually true and useful: the writing
18
+ * is safe, the HISTORY is what broke, and (sync always has a remote, so this
19
+ * always applies here) a fresh copy from online is the way back.
20
+ */
21
+ export declare const MSG_HISTORY_UNREADABLE = "This project's version history can't be read, so syncing can't continue. Your writing is safe \u2014 every file is still here on this computer. To get history and syncing working again, download a fresh copy of the project from online, then move any recent changes into it.";
22
+ export declare const MSG_UNRELATED = "This project and the online copy have no history in common, so they can't be combined. Either the online address points at a different project, or this project's own history was lost and started over. Your writing is safe \u2014 every file is still here on this computer. Check the online address; if it is correct, download a fresh copy from online and move any recent changes into it.";
16
23
  export declare const MSG_NO_REMOTE = "This project isn't connected to an online repository yet.";
17
24
  export declare const MSG_SSH_REMOTE = "This project's online address uses SSH (git@\u2026), which gutterpress can't sync to. Switch it to the web (HTTPS) address to sync from here.";
18
25
  export declare const MSG_NO_BRANCH = "This project's version history isn't on a named branch, so it can't be synced right now.";
19
- export declare const MSG_PULLED = "The latest online changes were downloaded to this computer.";
20
- export declare const MSG_PULLED_MERGED = "The latest online changes were combined with your changes on this computer.";
21
- export declare const MSG_PULL_UP_TO_DATE = "You already have the latest online changes.";
22
- export declare const MSG_PUSH_UP_TO_DATE = "There's nothing new to send \u2014 everything is already online.";
23
- export declare const MSG_PULL_FIRST = "The online copy has changes you don't have yet. Get the latest changes first, then send yours.";
24
- /** Message recorded on the automatic pre-sync snapshot (D5 invariant). */
25
- export declare const SYNC_SNAPSHOT_MESSAGE = "Snapshot before syncing";
26
+ /** Message recorded on the automatic pre-sync snapshot (D5 invariant).
27
+ * Pre-0.10.1 history carries the old spelling "Snapshot before syncing". */
28
+ export declare const SYNC_SNAPSHOT_MESSAGE = "Automatic backup of your work";
29
+ /**
30
+ * Message recorded when an edit reached disk while the fetch was in flight
31
+ * after the pre-sync snapshot, before the merge (see the re-snapshot in
32
+ * `syncProject`). Distinct wording so the race is legible in history; the
33
+ * copy is already writer-voiced, so it deliberately stays unchanged (every
34
+ * rename permanently grows the timeline's superseded-spelling list).
35
+ */
36
+ export declare const SYNC_LATE_EDIT_MESSAGE = "Saved the edit you made while syncing";
37
+ /**
38
+ * The keep-both sibling contract, kept HERE rather than in converge-merge.ts
39
+ * because every consumer needs it and converge-merge pulls in isomorphic-git:
40
+ * the markdown file resolver must not render a sibling as a chapter, and the
41
+ * merge-marker check must report it. This file imports nothing, so any of
42
+ * them can depend on it.
43
+ *
44
+ * `art/cover.png` -> `art/cover.online.png`; extensionless `NOTES` -> `NOTES.online`.
45
+ */
46
+ export declare function onlineSiblingPath(filepath: string): string;
47
+ /** True for a path `onlineSiblingPath` produced — Gutterpress's artifact, not the author's. */
48
+ export declare function isOnlineSibling(filepath: string): boolean;
@@ -16,26 +16,21 @@ import type { HostCredential, TokenStore } from "./token-store.ts";
16
16
  */
17
17
  export type GitCache = Record<string, unknown>;
18
18
  /**
19
- * One image file that changed on both sides. Sync already converged (the
20
- * NEWER side's bytes are on disk and committed the safe default); this
21
- * record lets a host offer a non-blocking side-by-side picker afterwards.
22
- * Both blob oids are pinned by the merge commit's two parents, so the picker
23
- * can never go stale.
19
+ * One file that changed on both sides and cannot carry conflict markers (a
20
+ * binary, or an SVG whose XML markers would break). Both versions are on
21
+ * disk and committed: ours stayed at `path`, the online one was written
22
+ * beside it. The host names the pair so the writer can fix it by hand.
24
23
  */
25
- export interface ImageClash {
26
- /** Repo-relative path of the image. */
24
+ export interface KeptBothFile {
25
+ /** Repo-relative path holding OUR version (unchanged). */
27
26
  path: string;
28
- /** Blob oid of the local version. */
29
- localOid: string;
30
- /** Blob oid of the online version. */
31
- remoteOid: string;
32
- /** Which side the automatic newer-wins policy kept on disk. */
33
- kept: "local" | "online";
27
+ /** Repo-relative path holding the ONLINE version (`name.online.ext`). */
28
+ onlinePath: string;
34
29
  }
35
30
  /** Outcome of a sync attempt. Sync always converges — there is no "conflict"
36
31
  * arm: overlapping text edits land in the file inside standard git conflict
37
- * markers (`combinedFiles`), clashing binaries keep the newer side
38
- * (`imageClashes` for images), and the other version is always reachable in
32
+ * markers (`combinedFiles`), clashing binaries keep BOTH versions as two
33
+ * files (`keptBothFiles`), and every version is always reachable in
39
34
  * history. Owner ruling 2026-08-14. */
40
35
  export type SyncOutcome = {
41
36
  status: "synced";
@@ -48,13 +43,17 @@ export type SyncOutcome = {
48
43
  filesChanged?: boolean;
49
44
  /** Files whose text now holds BOTH versions inside git conflict markers. */
50
45
  combinedFiles?: string[];
51
- /** Clashing images (newer kept) for the host's non-blocking picker. */
52
- imageClashes?: ImageClash[];
46
+ /** Files kept as a pair (ours at `path`, theirs at `onlinePath`). */
47
+ keptBothFiles?: KeptBothFile[];
53
48
  } | {
54
49
  status: "up-to-date";
55
50
  message: string;
56
51
  snapshotId?: string;
57
52
  filesChanged?: boolean;
53
+ /** A pull-merge-only pass (`push: false`) can combine overlapping edits
54
+ * and then hold the push — the converge report still surfaces here. */
55
+ combinedFiles?: string[];
56
+ keptBothFiles?: KeptBothFile[];
58
57
  } | {
59
58
  status: "auth";
60
59
  message: string;
@@ -68,90 +67,9 @@ export type SyncOutcome = {
68
67
  } | {
69
68
  status: "error";
70
69
  message: string;
71
- /**
72
- * Stable machine-readable signal so a host UI can route without
73
- * string-matching `message` (which stays free to reword):
74
- * "needs-connection-setup" — the project isn't set up right (no
75
- * remote / SSH remote / no named branch); route to the connect/setup
76
- * surface.
77
- */
78
- code?: "needs-connection-setup";
79
70
  snapshotId?: string;
80
71
  filesChanged?: boolean;
81
72
  };
82
- /**
83
- * Outcome of a pull-only attempt ({@link pullChanges}): fetch + fast-forward/
84
- * converge-merge of the online changes, NEVER a push. The merge always lands
85
- * (see converge-merge.ts) — there is no "conflict" arm.
86
- */
87
- export type PullOutcome = {
88
- status: "pulled";
89
- message: string;
90
- /** Snapshot taken of unsaved work before pulling, if any. */
91
- snapshotId?: string;
92
- /**
93
- * True when the pull created a combine (merge) commit — local commits
94
- * existed alongside the online ones. False for a plain fast-forward.
95
- */
96
- merged: boolean;
97
- /**
98
- * True when the working tree CONTENT changed (the tip's tree differs
99
- * from before) — the host should refresh its preview.
100
- */
101
- filesChanged: boolean;
102
- /** Files whose text now holds BOTH versions inside git conflict markers. */
103
- combinedFiles?: string[];
104
- /** Clashing images (newer kept) for the host's non-blocking picker. */
105
- imageClashes?: ImageClash[];
106
- } | {
107
- status: "up-to-date";
108
- message: string;
109
- snapshotId?: string;
110
- } | {
111
- status: "auth";
112
- message: string;
113
- snapshotId?: string;
114
- } | {
115
- status: "offline";
116
- message: string;
117
- snapshotId?: string;
118
- } | {
119
- status: "error";
120
- message: string;
121
- snapshotId?: string;
122
- };
123
- /**
124
- * Outcome of a push-only attempt ({@link pushChanges}): snapshot-if-needed,
125
- * then push — NEVER a merge. When the online copy has commits this computer
126
- * doesn't have, the result is the typed `"pull-first"` status (the host shows
127
- * a plain-language "get the latest changes first" message) — pushChanges
128
- * never auto-merges.
129
- */
130
- export type PushOutcome = {
131
- status: "pushed";
132
- message: string;
133
- snapshotId?: string;
134
- } | {
135
- status: "up-to-date";
136
- message: string;
137
- snapshotId?: string;
138
- } | {
139
- status: "pull-first";
140
- message: string;
141
- snapshotId?: string;
142
- } | {
143
- status: "auth";
144
- message: string;
145
- snapshotId?: string;
146
- } | {
147
- status: "offline";
148
- message: string;
149
- snapshotId?: string;
150
- } | {
151
- status: "error";
152
- message: string;
153
- snapshotId?: string;
154
- };
155
73
  export interface SyncProjectOptions {
156
74
  projectDir: string;
157
75
  /** Explicit credential; wins over the token store. */
@@ -162,12 +80,23 @@ export interface SyncProjectOptions {
162
80
  message?: string;
163
81
  authorName?: string;
164
82
  authorEmail?: string;
83
+ /**
84
+ * When `false`, run a pull-merge-only pass: fetch + converge-merge exactly
85
+ * as always (remote work still arrives promptly), but hold the network push
86
+ * — and mint no snapshot unless the remote moved and the merge needs one
87
+ * (D5), so a quiet pass creates no commit at all. Local work stays safely
88
+ * committed/on disk for the next push-enabled pass to send. Default `true`:
89
+ * the full snapshot-first → fetch → merge → push pass. Owner decision
90
+ * 2026-08-23 (push cadence): the desktop's 2-minute tick pulls every time
91
+ * and enables the push only every ~15 minutes and on app exit.
92
+ */
93
+ push?: boolean;
165
94
  /** Injectable git HTTP transport for tests. */
166
95
  httpClient?: typeof httpNode;
167
96
  /**
168
- * Bounded retry policy for {@link syncProject}'s pull→push race loop. The
169
- * loop is ALWAYS bounded (never infinite) and the snapshot-first guarantee
170
- * holds on every path. Defaults to {@link DEFAULT_SYNC_RETRY}. `sleep` is
97
+ * Bounded retry policy for {@link syncProject}'s fetchmerge→push race
98
+ * loop. The loop is ALWAYS bounded (never infinite) and the snapshot-first
99
+ * guarantee holds on every path. Defaults to {@link DEFAULT_SYNC_RETRY}. `sleep` is
171
100
  * injectable so tests can drive backoff deterministically.
172
101
  */
173
102
  retry?: SyncRetryOptions;
@@ -1,30 +1,28 @@
1
1
  import { onAuthFor } from "./transport.ts";
2
- import type { PullOutcome, PushOutcome, SyncOutcome, SyncProjectOptions } from "./sync-types.ts";
2
+ import type { SyncOutcome, SyncProjectOptions } from "./sync-types.ts";
3
3
  export { onAuthFor };
4
4
  export { SYNC_SNAPSHOT_MESSAGE } from "./sync-messages.ts";
5
- export type { ImageClash, PullOutcome, PushOutcome, SyncOutcome, SyncProjectOptions, } from "./sync-types.ts";
5
+ export type { KeptBothFile, SyncOutcome, SyncProjectOptions, } from "./sync-types.ts";
6
6
  /**
7
- * Snapshot-first sync (ADR 0006 D5) the composition of {@link pullChanges}
8
- * then {@link pushChanges}. If someone pushes between our pull and our push,
9
- * the push reports pull-first and the pair re-runs (their commits converge in
10
- * on the next pass), with a short backoff between passes. The loop is ALWAYS
11
- * bounded by `retry.attempts`; a remote that races every pass surfaces a
12
- * friendly "try again in a moment" rather than looping forever. Never throws
13
- * for expected outcomes — everything is reported through {@link SyncOutcome}.
7
+ * A push the remote refused because our branch is behind — the pull-first
8
+ * situation the retry loop exists for.
14
9
  */
15
- export declare function syncProject(options: SyncProjectOptions): Promise<SyncOutcome>;
10
+ export declare function isPushRejected(e: unknown): boolean;
16
11
  /**
17
- * Pull-only operation (the History tab's "Pull"): snapshot-if-needed
18
- * fetch fast-forward or CONVERGE-merge of the online changes NEVER a
19
- * push, never a question. Serialized on the per-repo lock; one
20
- * function-scoped object cache, released on return.
12
+ * Unrelated histories the local project and the configured online project
13
+ * share no common starting point. Sync surfaces this as a plain setup error
14
+ * (a wrong online address must never be silently spliced into the book).
21
15
  */
22
- export declare function pullChanges(options: SyncProjectOptions): Promise<PullOutcome>;
16
+ export declare function isUnrelatedHistories(e: unknown): boolean;
23
17
  /**
24
- * Push-only operation (the History tab's "Push"): snapshot-if-needed push.
25
- * If the online copy has commits this computer doesn't have (non-fast-forward),
26
- * it does NOT auto-merge — the typed `"pull-first"` result tells the host to
27
- * show a plain-language "get the latest changes first" message. Serialized on
28
- * the per-repo lock; one function-scoped object cache, released on return.
18
+ * Snapshot-first sync (ADR 0006 D5). Serialized on the per-repo lock; one
19
+ * function-scoped object cache, released on return.
20
+ *
21
+ * If someone pushes between our fetch and our push, the push is rejected and
22
+ * the fetch→merge→push pass re-runs (their commits converge in on the next
23
+ * fetch), with a short backoff between passes. The loop is ALWAYS bounded by
24
+ * `retry.attempts`; a remote that races every pass surfaces a friendly "try
25
+ * again in a moment" rather than looping forever. Never throws for expected
26
+ * outcomes — everything is reported through {@link SyncOutcome}.
29
27
  */
30
- export declare function pushChanges(options: SyncProjectOptions): Promise<PushOutcome>;
28
+ export declare function syncProject(options: SyncProjectOptions): Promise<SyncOutcome>;
@@ -25,10 +25,6 @@ export interface TokenStore {
25
25
  /** All stored credentials (used by "connected accounts" UIs). */
26
26
  list(): Promise<HostCredential[]>;
27
27
  }
28
- /** A credential with the token value masked — safe for logs/diagnostics. */
29
- export declare function redactCredential(cred: HostCredential): Omit<HostCredential, "token"> & {
30
- token: string;
31
- };
32
28
  /**
33
29
  * THE canonical credential-store key for a remote host — the ONE derivation
34
30
  * every writer (GitHub device flow, generic connect, embedded-URL migration)
@@ -87,9 +83,3 @@ export interface UrlCredentialExtraction {
87
83
  * display. Non-HTTP(S) or unparseable URLs pass through unchanged.
88
84
  */
89
85
  export declare function extractUrlCredential(url: string): UrlCredentialExtraction;
90
- /**
91
- * Migrate any credential embedded in `url` into `store` (only when the store
92
- * has no existing credential for that host — a stored credential is fresher
93
- * than one fossilized in a clone URL) and return the sanitized URL.
94
- */
95
- export declare function migrateUrlCredential(url: string, store: TokenStore): Promise<string>;