gitwrit 0.1.0

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.
@@ -0,0 +1,18 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const WORDS_FILE = join(dirname(fileURLToPath(import.meta.url)), 'words.json');
6
+
7
+ // picks one random entry from each of adjectives, nouns, verbs
8
+ // and joins them with hyphens.
9
+ // e.g. "crimson-walrus-stumbling"
10
+ export async function generateBranchName() {
11
+ const raw = await readFile(WORDS_FILE, 'utf8');
12
+ const { adjectives, nouns, verbs } = JSON.parse(raw);
13
+ return [pick(adjectives), pick(nouns), pick(verbs)].join('-');
14
+ }
15
+
16
+ function pick(arr) {
17
+ return arr[Math.floor(Math.random() * arr.length)];
18
+ }
package/src/git.js ADDED
@@ -0,0 +1,77 @@
1
+ import simpleGit from 'simple-git';
2
+
3
+ export async function isGitRepo(dir) {
4
+ try {
5
+ const git = simpleGit(dir);
6
+ await git.revparse(['--git-dir']);
7
+ return true;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+
13
+ export async function hasRemote(dir) {
14
+ const git = simpleGit(dir);
15
+ const remotes = await git.getRemotes();
16
+ return remotes.length > 0;
17
+ }
18
+
19
+ export async function getCurrentBranch(dir) {
20
+ const git = simpleGit(dir);
21
+ const name = await git.revparse(['--abbrev-ref', 'HEAD']);
22
+ return name.trim();
23
+ }
24
+
25
+ export async function branchExists(dir, name) {
26
+ const git = simpleGit(dir);
27
+ const branches = await git.branchLocal();
28
+ return branches.all.includes(name);
29
+ }
30
+
31
+ export async function createAndCheckoutBranch(dir, name) {
32
+ const git = simpleGit(dir);
33
+ await git.checkoutLocalBranch(name);
34
+ }
35
+
36
+ export async function commitFile(dir, filepath, message) {
37
+ const git = simpleGit(dir);
38
+ await git.add(filepath);
39
+ const status = await git.status();
40
+ if (status.staged.length === 0) return false;
41
+ await git.commit(message);
42
+ return true;
43
+ }
44
+
45
+ export async function commitAll(dir, message) {
46
+ const git = simpleGit(dir);
47
+ await git.add('.');
48
+ const status = await git.status();
49
+ if (status.staged.length === 0) return 0;
50
+ await git.commit(message);
51
+ return status.staged.length;
52
+ }
53
+
54
+ export async function push(dir) {
55
+ const git = simpleGit(dir);
56
+ const branch = await getCurrentBranch(dir);
57
+ await git.push('origin', branch, ['--set-upstream']);
58
+ }
59
+
60
+ export async function getUnpushedCount(dir) {
61
+ try {
62
+ const git = simpleGit(dir);
63
+ const result = await git.log(['@{u}..HEAD']);
64
+ return result.total;
65
+ } catch {
66
+ return 1;
67
+ }
68
+ }
69
+
70
+ export async function getRepoSummary(dir) {
71
+ const git = simpleGit(dir);
72
+ const branch = await getCurrentBranch(dir);
73
+ const unpushed = await getUnpushedCount(dir);
74
+ const log = await git.log(['-1']);
75
+ const lastCommit = log.latest;
76
+ return { branch, unpushed, lastCommit };
77
+ }
package/src/logger.js ADDED
@@ -0,0 +1,53 @@
1
+ import { appendFile, mkdir, readFile } from 'fs/promises';
2
+ import { GITWRIT_DIR, LOG_FILE } from './paths.js';
3
+
4
+ // ─── setup ────────────────────────────────────────────────────────────────────
5
+
6
+ async function ensureLogDir() {
7
+ await mkdir(GITWRIT_DIR, { recursive: true });
8
+ }
9
+
10
+ // ─── format ───────────────────────────────────────────────────────────────────
11
+
12
+ function timestamp() {
13
+ return new Date().toLocaleTimeString('en-US', {
14
+ hour: '2-digit',
15
+ minute: '2-digit',
16
+ second: '2-digit',
17
+ hour12: false,
18
+ });
19
+ }
20
+
21
+ // ─── write ────────────────────────────────────────────────────────────────────
22
+
23
+ async function write(level, message) {
24
+ await ensureLogDir();
25
+ const line = `${timestamp()} ${level.padEnd(8)} ${message}\n`;
26
+ await appendFile(LOG_FILE, line, 'utf8');
27
+ }
28
+
29
+ // ─── public api ───────────────────────────────────────────────────────────────
30
+
31
+ export const logger = {
32
+ info: (msg) => write('info', msg),
33
+ warn: (msg) => write('warn', msg),
34
+ error: (msg) => write('error', msg),
35
+ commit: (msg) => write('commit', msg),
36
+ push: (msg) => write('push', msg),
37
+ pause: (msg) => write('pause', msg),
38
+ resume: (msg) => write('resume', msg),
39
+ };
40
+
41
+ // ─── tail ─────────────────────────────────────────────────────────────────────
42
+
43
+ // returns the last `n` lines of the log file as an array.
44
+ // used by `gitwrit logs`.
45
+ export async function tailLog(n = 20) {
46
+ try {
47
+ const raw = await readFile(LOG_FILE, 'utf8');
48
+ const lines = raw.split('\n').filter(Boolean);
49
+ return lines.slice(-n);
50
+ } catch {
51
+ return []; // log doesn't exist yet
52
+ }
53
+ }
package/src/paths.js ADDED
@@ -0,0 +1,27 @@
1
+ import { homedir } from 'os';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ // project root (for bundled assets like expansions.txt)
6
+ export const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
7
+
8
+ // user's home directory
9
+ export const HOME_DIR = homedir();
10
+
11
+ // ~/.gitwrit/ — runtime files only (pid, log, state)
12
+ export const GITWRIT_DIR = join(HOME_DIR, '.gitwrit');
13
+
14
+ // daemon PID — used by start/stop/status to track the process
15
+ export const PID_FILE = join(GITWRIT_DIR, 'gitwrit.pid');
16
+
17
+ // append-only activity log — used by `gitwrit logs`
18
+ export const LOG_FILE = join(GITWRIT_DIR, 'gitwrit.log');
19
+
20
+ // daemon state — persists paused/stopped across sessions
21
+ export const STATE_FILE = join(GITWRIT_DIR, 'gitwrit.state');
22
+
23
+ // global user config — lives at ~/.gitwritrc.json
24
+ export const GLOBAL_CONFIG_FILE = join(HOME_DIR, '.gitwritrc.json');
25
+
26
+ // local directory config — lives at <watched-dir>/.gitwrit.json
27
+ export const LOCAL_CONFIG_FILENAME = '.gitwrit.json';
@@ -0,0 +1,46 @@
1
+ import { push, getUnpushedCount } from './git.js';
2
+ import { logger } from './logger.js';
3
+ import { writeState, readState } from './state.js';
4
+
5
+ // ─── scheduler ────────────────────────────────────────────────────────────────
6
+
7
+ // maintains a set of repos that have unpushed commits and pushes them
8
+ // on a fixed interval. safe to call multiple times — push is idempotent.
9
+ export function createScheduler({ interval, onPushComplete }) {
10
+ const pendingRepos = new Set();
11
+
12
+ async function flush() {
13
+ for (const dir of [...pendingRepos]) {
14
+ try {
15
+ const unpushed = await getUnpushedCount(dir);
16
+ if (unpushed === 0) {
17
+ pendingRepos.delete(dir);
18
+ continue;
19
+ }
20
+
21
+ await push(dir);
22
+ pendingRepos.delete(dir);
23
+ await logger.push(`${dir}`);
24
+
25
+ const state = await readState();
26
+ await writeState({ lastPushedAt: new Date().toISOString() });
27
+
28
+ if (onPushComplete) onPushComplete(dir);
29
+ } catch (err) {
30
+ await logger.error(`push failed for ${dir}: ${err.message}`);
31
+ }
32
+ }
33
+ }
34
+
35
+ const timer = setInterval(flush, interval);
36
+
37
+ return {
38
+ // call this after every successful local commit
39
+ queue: (dir) => pendingRepos.add(dir),
40
+
41
+ // flush immediately (e.g. on wake from sleep)
42
+ flushNow: () => flush(),
43
+
44
+ stop: () => clearInterval(timer),
45
+ };
46
+ }
@@ -0,0 +1,115 @@
1
+ import { readFile, writeFile, access } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { GLOBAL_CONFIG_FILE, LOCAL_CONFIG_FILENAME } from './paths.js';
4
+
5
+ // ─── defaults ────────────────────────────────────────────────────────────────
6
+
7
+ export const DEFAULTS = {
8
+ fileTypes: ['.md', '.mdx'],
9
+ debounce: 3000, // ms — wait after last save before committing
10
+ pushInterval: 300000, // ms — time between remote pushes (5 min)
11
+ branchMode: 'current', // 'current' | 'autogenerated'
12
+ watch: [], // array of { type: 'directory'|'repo', path: string }
13
+ };
14
+
15
+ // ─── existence checks ─────────────────────────────────────────────────────────
16
+
17
+ export async function globalConfigExists() {
18
+ try {
19
+ await access(GLOBAL_CONFIG_FILE);
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export async function localConfigExists(dir) {
27
+ try {
28
+ await access(join(dir, LOCAL_CONFIG_FILENAME));
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ // ─── loaders ─────────────────────────────────────────────────────────────────
36
+
37
+ export async function loadGlobalConfig() {
38
+ const raw = await readFile(GLOBAL_CONFIG_FILE, 'utf8');
39
+ return JSON.parse(raw);
40
+ }
41
+
42
+ export async function loadLocalConfig(dir) {
43
+ try {
44
+ const raw = await readFile(join(dir, LOCAL_CONFIG_FILENAME), 'utf8');
45
+ return JSON.parse(raw);
46
+ } catch {
47
+ return null; // null = no local overrides, follow global
48
+ }
49
+ }
50
+
51
+ // ─── writers ──────────────────────────────────────────────────────────────────
52
+
53
+ export async function saveGlobalConfig(config) {
54
+ await writeFile(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
55
+ }
56
+
57
+ export async function saveLocalConfig(dir, overrides) {
58
+ const localPath = join(dir, LOCAL_CONFIG_FILENAME);
59
+ await writeFile(localPath, JSON.stringify(overrides, null, 2), 'utf8');
60
+ }
61
+
62
+ // ─── resolver ─────────────────────────────────────────────────────────────────
63
+
64
+ // merges global config with any local overrides for a given directory.
65
+ // local keys win — unset local keys fall through to global.
66
+ export async function resolveConfig(dir) {
67
+ const global = await loadGlobalConfig();
68
+ const local = await loadLocalConfig(dir);
69
+ if (!local) return { ...global, _source: 'global' };
70
+ return { ...global, ...local, _source: 'local' };
71
+ }
72
+
73
+ // ─── validation ───────────────────────────────────────────────────────────────
74
+
75
+ export function validateConfig(config) {
76
+ const errors = [];
77
+
78
+ if (!Array.isArray(config.fileTypes) || config.fileTypes.length === 0) {
79
+ errors.push('fileTypes must be a non-empty array (e.g. [".md", ".mdx"])');
80
+ }
81
+
82
+ if (typeof config.debounce !== 'number' || config.debounce < 500) {
83
+ errors.push('debounce must be a number >= 500ms');
84
+ }
85
+
86
+ if (typeof config.pushInterval !== 'number' || config.pushInterval < 60000) {
87
+ errors.push('pushInterval must be a number >= 60000ms (1 minute)');
88
+ }
89
+
90
+ if (!['current', 'autogenerated'].includes(config.branchMode)) {
91
+ errors.push('branchMode must be "current" or "autogenerated"');
92
+ }
93
+
94
+ return errors;
95
+ }
96
+
97
+ // ─── watch list helpers ───────────────────────────────────────────────────────
98
+
99
+ export async function addWatchPath(entry) {
100
+ const config = await loadGlobalConfig();
101
+ const already = config.watch.some(w => w.path === entry.path);
102
+ if (already) return false;
103
+ config.watch.push(entry);
104
+ await saveGlobalConfig(config);
105
+ return true;
106
+ }
107
+
108
+ export async function removeWatchPath(dirPath) {
109
+ const config = await loadGlobalConfig();
110
+ const before = config.watch.length;
111
+ config.watch = config.watch.filter(w => w.path !== dirPath);
112
+ if (config.watch.length === before) return false;
113
+ await saveGlobalConfig(config);
114
+ return true;
115
+ }
package/src/state.js ADDED
@@ -0,0 +1,46 @@
1
+ import { readFile, writeFile, mkdir } from 'fs/promises';
2
+ import { GITWRIT_DIR, STATE_FILE } from './paths.js';
3
+
4
+ // ─── state values ─────────────────────────────────────────────────────────────
5
+
6
+ export const STATE = {
7
+ RUNNING: 'running',
8
+ PAUSED: 'paused',
9
+ STOPPED: 'stopped',
10
+ };
11
+
12
+ // ─── helpers ──────────────────────────────────────────────────────────────────
13
+
14
+ async function ensureDir() {
15
+ await mkdir(GITWRIT_DIR, { recursive: true });
16
+ }
17
+
18
+ export async function readState() {
19
+ try {
20
+ const raw = await readFile(STATE_FILE, 'utf8');
21
+ return JSON.parse(raw);
22
+ } catch {
23
+ return { status: STATE.STOPPED };
24
+ }
25
+ }
26
+
27
+ export async function writeState(patch) {
28
+ await ensureDir();
29
+ const current = await readState();
30
+ const next = { ...current, ...patch, updatedAt: new Date().toISOString() };
31
+ await writeFile(STATE_FILE, JSON.stringify(next, null, 2), 'utf8');
32
+ }
33
+
34
+ // ─── convenience writers ──────────────────────────────────────────────────────
35
+
36
+ export async function markRunning(sessionData = {}) {
37
+ await writeState({ status: STATE.RUNNING, ...sessionData });
38
+ }
39
+
40
+ export async function markPaused() {
41
+ await writeState({ status: STATE.PAUSED, pausedAt: new Date().toISOString() });
42
+ }
43
+
44
+ export async function markStopped() {
45
+ await writeState({ status: STATE.STOPPED, pausedAt: null });
46
+ }
package/src/ui.js ADDED
@@ -0,0 +1,63 @@
1
+ import chalk from 'chalk';
2
+
3
+ // ─── sigil + brand ────────────────────────────────────────────────────────────
4
+
5
+ export const SIGIL = '✦';
6
+
7
+ export const brand = (msg) => chalk.white(`\n ${SIGIL} ${msg}`);
8
+ export const info = (msg) => chalk.white(` ${msg}`);
9
+ export const good = (msg) => chalk.green(` ✔ ${msg}`);
10
+ export const bad = (msg) => chalk.red(` ✗ ${msg}`);
11
+ export const warn = (msg) => chalk.yellow(` ⚠ ${msg}`);
12
+ export const hint = (msg) => chalk.dim(` ${msg}`);
13
+ export const divider = (label) =>
14
+ chalk.dim(`\n ${'─'.repeat(41)}\n ${label}\n ${'─'.repeat(41)}`);
15
+
16
+ // ─── key/value rows ───────────────────────────────────────────────────────────
17
+
18
+ // prints a two-column row like:
19
+ // watching ~/notes
20
+ export function row(key, value, tag) {
21
+ const k = chalk.dim(key.padEnd(14));
22
+ const v = chalk.white(value);
23
+ const t = tag ? chalk.dim(` (${tag})`) : '';
24
+ return ` ${k}${v}${t}`;
25
+ }
26
+
27
+ // ─── spacing ──────────────────────────────────────────────────────────────────
28
+
29
+ export const gap = () => console.log();
30
+
31
+ // ─── printers ─────────────────────────────────────────────────────────────────
32
+
33
+ export const print = {
34
+ brand: (msg) => console.log(brand(msg)),
35
+ info: (msg) => console.log(info(msg)),
36
+ good: (msg) => console.log(good(msg)),
37
+ bad: (msg) => console.log(bad(msg)),
38
+ warn: (msg) => console.log(warn(msg)),
39
+ hint: (msg) => console.log(hint(msg)),
40
+ divider: (label) => console.log(divider(label)),
41
+ row: (key, value, tag) => console.log(row(key, value, tag)),
42
+ gap,
43
+ };
44
+
45
+ // ─── time formatting ──────────────────────────────────────────────────────────
46
+
47
+ export function timeAgo(date) {
48
+ if (!date) return 'never';
49
+ const seconds = Math.floor((Date.now() - new Date(date)) / 1000);
50
+ if (seconds < 10) return 'just now';
51
+ if (seconds < 60) return `${seconds}s ago`;
52
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
53
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
54
+ return `${Math.floor(seconds / 86400)}d ago`;
55
+ }
56
+
57
+ // ─── ms formatting ────────────────────────────────────────────────────────────
58
+
59
+ export function formatMs(ms) {
60
+ if (ms < 1000) return `${ms}ms`;
61
+ if (ms < 60000) return `${ms / 1000}s`;
62
+ return `${ms / 60000} min`;
63
+ }
package/src/watcher.js ADDED
@@ -0,0 +1,59 @@
1
+ import chokidar from 'chokidar';
2
+
3
+ // ─── watcher factory ──────────────────────────────────────────────────────────
4
+
5
+ // starts a chokidar watcher on the given paths, filtered to fileTypes.
6
+ // calls onSave(filepath, dir) after the per-file debounce window.
7
+ export function createWatcher({ paths, fileTypes, debounce, onSave }) {
8
+ // build a glob pattern for each watched path
9
+ const globs = paths.map(p => `${p}/**/*`);
10
+
11
+ const watcher = chokidar.watch(globs, {
12
+ ignored: [
13
+ /(^|[/\\])\../, // dotfiles
14
+ /node_modules/,
15
+ /\.git/,
16
+ ],
17
+ persistent: true,
18
+ ignoreInitial: true,
19
+ awaitWriteFinish: {
20
+ stabilityThreshold: 200, // wait 200ms for the write to fully flush
21
+ pollInterval: 100,
22
+ },
23
+ });
24
+
25
+ // per-file debounce timers — each file gets its own timer
26
+ const timers = new Map();
27
+
28
+ function handleChange(filepath) {
29
+ // only act on files matching the configured types
30
+ const matches = fileTypes.some(ext => filepath.endsWith(ext));
31
+ if (!matches) return;
32
+
33
+ // find which watched directory this file belongs to
34
+ const dir = paths.find(p => filepath.startsWith(p));
35
+ if (!dir) return;
36
+
37
+ // reset the timer for this specific file
38
+ if (timers.has(filepath)) clearTimeout(timers.get(filepath));
39
+
40
+ const timer = setTimeout(() => {
41
+ timers.delete(filepath);
42
+ onSave(filepath, dir);
43
+ }, debounce);
44
+
45
+ timers.set(filepath, timer);
46
+ }
47
+
48
+ watcher.on('add', handleChange);
49
+ watcher.on('change', handleChange);
50
+
51
+ return {
52
+ close: () => watcher.close(),
53
+ // re-attach watchers after sleep — called on SIGCONT
54
+ restart: () => {
55
+ watcher.close();
56
+ return createWatcher({ paths, fileTypes, debounce, onSave });
57
+ },
58
+ };
59
+ }
package/src/words.json ADDED
@@ -0,0 +1,128 @@
1
+ {
2
+ "adjectives": [
3
+ "amber",
4
+ "angry",
5
+ "blue",
6
+ "breezy",
7
+ "calm",
8
+ "coral",
9
+ "crimson",
10
+ "dizzy",
11
+ "drowsy",
12
+ "fancy",
13
+ "fuzzy",
14
+ "gentle",
15
+ "gloomy",
16
+ "golden",
17
+ "grumpy",
18
+ "happy",
19
+ "hungry",
20
+ "indigo",
21
+ "itchy",
22
+ "ivory",
23
+ "jade",
24
+ "jolly",
25
+ "lavender",
26
+ "little",
27
+ "lonely",
28
+ "loud",
29
+ "nervous",
30
+ "olive",
31
+ "polite",
32
+ "rusty",
33
+ "scarlet",
34
+ "shaggy",
35
+ "silent",
36
+ "silver",
37
+ "sleepy",
38
+ "teal",
39
+ "tiny",
40
+ "violet",
41
+ "wobbly",
42
+ "yellow"
43
+ ],
44
+ "nouns": [
45
+ "acorn",
46
+ "anvil",
47
+ "badger",
48
+ "biscuit",
49
+ "blanket",
50
+ "broom",
51
+ "bucket",
52
+ "cactus",
53
+ "candle",
54
+ "cloud",
55
+ "coyote",
56
+ "door",
57
+ "ferret",
58
+ "flamingo",
59
+ "fox",
60
+ "gerbil",
61
+ "hammer",
62
+ "hedgehog",
63
+ "kettle",
64
+ "lantern",
65
+ "lemur",
66
+ "lizard",
67
+ "marble",
68
+ "narwhal",
69
+ "noodle",
70
+ "otter",
71
+ "pebble",
72
+ "penguin",
73
+ "pillow",
74
+ "panther",
75
+ "raccoon",
76
+ "radish",
77
+ "spatula",
78
+ "sponge",
79
+ "teapot",
80
+ "tree",
81
+ "trumpet",
82
+ "walrus",
83
+ "wombat",
84
+ "zebra"
85
+ ],
86
+ "verbs": [
87
+ "balancing",
88
+ "blinking",
89
+ "bouncing",
90
+ "brooding",
91
+ "clapping",
92
+ "climbing",
93
+ "clattering",
94
+ "dreaming",
95
+ "drifting",
96
+ "fidgeting",
97
+ "floating",
98
+ "galloping",
99
+ "giggling",
100
+ "grumbling",
101
+ "hovering",
102
+ "humming",
103
+ "juggling",
104
+ "jumping",
105
+ "leaping",
106
+ "marching",
107
+ "mumbling",
108
+ "napping",
109
+ "nodding",
110
+ "pondering",
111
+ "rambling",
112
+ "running",
113
+ "rustling",
114
+ "shuffling",
115
+ "sighing",
116
+ "skipping",
117
+ "sleeping",
118
+ "slouching",
119
+ "spinning",
120
+ "sprinting",
121
+ "squinting",
122
+ "stumbling",
123
+ "swaying",
124
+ "waddling",
125
+ "yawning",
126
+ "yodeling"
127
+ ]
128
+ }