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.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/bin/gitwrit.js +76 -0
- package/package.json +27 -0
- package/src/commands/add.js +44 -0
- package/src/commands/config.js +250 -0
- package/src/commands/help.js +53 -0
- package/src/commands/init.js +172 -0
- package/src/commands/logs.js +33 -0
- package/src/commands/remove.js +40 -0
- package/src/commands/restart.js +10 -0
- package/src/commands/start.js +118 -0
- package/src/commands/status.js +94 -0
- package/src/commands/stop.js +57 -0
- package/src/daemon.js +157 -0
- package/src/expansions.js +18 -0
- package/src/git.js +77 -0
- package/src/logger.js +53 -0
- package/src/paths.js +27 -0
- package/src/scheduler.js +46 -0
- package/src/settings.js +115 -0
- package/src/state.js +46 -0
- package/src/ui.js +63 -0
- package/src/watcher.js +59 -0
- package/src/words.json +128 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { checkbox, select, confirm, input } from '@inquirer/prompts';
|
|
2
|
+
import { mkdir } from 'fs/promises';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { cwd } from 'process';
|
|
5
|
+
|
|
6
|
+
import { print } from '../ui.js';
|
|
7
|
+
import {
|
|
8
|
+
DEFAULTS,
|
|
9
|
+
globalConfigExists,
|
|
10
|
+
loadGlobalConfig,
|
|
11
|
+
saveGlobalConfig,
|
|
12
|
+
addWatchPath,
|
|
13
|
+
} from '../settings.js';
|
|
14
|
+
import { GITWRIT_DIR } from '../paths.js';
|
|
15
|
+
import { isGitRepo, hasRemote } from '../git.js';
|
|
16
|
+
|
|
17
|
+
// ─── main ─────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export async function init() {
|
|
20
|
+
const dir = resolve(cwd());
|
|
21
|
+
const isFirstRun = !(await globalConfigExists());
|
|
22
|
+
|
|
23
|
+
// ── first-run: global setup ────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
if (isFirstRun) {
|
|
26
|
+
print.brand("Welcome to gitwrit. Let's get you set up.");
|
|
27
|
+
print.gap();
|
|
28
|
+
print.divider('Global defaults');
|
|
29
|
+
print.hint('These apply to every directory you watch.');
|
|
30
|
+
print.gap();
|
|
31
|
+
|
|
32
|
+
await mkdir(GITWRIT_DIR, { recursive: true });
|
|
33
|
+
|
|
34
|
+
const fileTypes = await checkbox({
|
|
35
|
+
message: 'Which file types should gitwrit watch?',
|
|
36
|
+
choices: [
|
|
37
|
+
{ name: '.md', value: '.md', checked: true },
|
|
38
|
+
{ name: '.mdx', value: '.mdx', checked: true },
|
|
39
|
+
{ name: '.txt', value: '.txt', checked: false },
|
|
40
|
+
{ name: '.rst', value: '.rst', checked: false },
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const debounceChoice = await select({
|
|
45
|
+
message: 'How long should gitwrit wait after a save before committing?',
|
|
46
|
+
choices: [
|
|
47
|
+
{ name: '3 seconds (recommended)', value: 3000 },
|
|
48
|
+
{ name: '5 seconds', value: 5000 },
|
|
49
|
+
{ name: '10 seconds', value: 10000 },
|
|
50
|
+
{ name: 'Custom', value: 'custom' },
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
let debounce = debounceChoice;
|
|
55
|
+
if (debounceChoice === 'custom') {
|
|
56
|
+
const raw = await input({ message: 'Seconds to wait after a save:' });
|
|
57
|
+
debounce = parseFloat(raw) * 1000;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const pushChoice = await select({
|
|
61
|
+
message: 'How often should gitwrit push to remote?',
|
|
62
|
+
choices: [
|
|
63
|
+
{ name: 'Every 5 minutes (recommended)', value: 300000 },
|
|
64
|
+
{ name: 'Every 10 minutes', value: 600000 },
|
|
65
|
+
{ name: 'Every 30 minutes', value: 1800000 },
|
|
66
|
+
{ name: 'Custom', value: 'custom' },
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let pushInterval = pushChoice;
|
|
71
|
+
if (pushChoice === 'custom') {
|
|
72
|
+
const raw = await input({ message: 'Minutes between pushes:' });
|
|
73
|
+
pushInterval = parseFloat(raw) * 60000;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const branchMode = await select({
|
|
77
|
+
message: 'Default branch mode?',
|
|
78
|
+
choices: [
|
|
79
|
+
{
|
|
80
|
+
name: "Current branch — commit to whatever branch you're on",
|
|
81
|
+
value: 'current',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'Autogenerated — create a fresh named branch each session (e.g. crimson-walrus-stumbling)',
|
|
85
|
+
value: 'autogenerated',
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const globalConfig = {
|
|
91
|
+
...DEFAULTS,
|
|
92
|
+
fileTypes,
|
|
93
|
+
debounce,
|
|
94
|
+
pushInterval,
|
|
95
|
+
branchMode,
|
|
96
|
+
watch: [],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
await saveGlobalConfig(globalConfig);
|
|
100
|
+
print.gap();
|
|
101
|
+
print.good('Global defaults saved.');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── register current directory ────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
print.gap();
|
|
107
|
+
print.divider(`Registering ${dir}`);
|
|
108
|
+
print.gap();
|
|
109
|
+
|
|
110
|
+
// hard gate: must be a git repo
|
|
111
|
+
const repoValid = await isGitRepo(dir);
|
|
112
|
+
if (!repoValid) {
|
|
113
|
+
print.bad(`${dir} is not a Git repo.`);
|
|
114
|
+
print.hint('Run git init here first, then run gitwrit init again.');
|
|
115
|
+
print.gap();
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// hard gate: must have a remote
|
|
120
|
+
const remoteValid = await hasRemote(dir);
|
|
121
|
+
if (!remoteValid) {
|
|
122
|
+
print.bad('No Git remote configured.');
|
|
123
|
+
print.hint('gitwrit pushes your work to a remote repository to keep it safe.');
|
|
124
|
+
print.hint('Add one first: git remote add origin <url>');
|
|
125
|
+
print.hint('Then run gitwrit init again.');
|
|
126
|
+
print.gap();
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
print.good('Valid Git repo found.');
|
|
131
|
+
print.good('Remote origin configured.');
|
|
132
|
+
print.gap();
|
|
133
|
+
|
|
134
|
+
// local overrides — only ask if not first run (first run just inherits global)
|
|
135
|
+
let useGlobalDefaults = true;
|
|
136
|
+
if (!isFirstRun) {
|
|
137
|
+
useGlobalDefaults = await confirm({
|
|
138
|
+
message: 'Use global defaults for this directory?',
|
|
139
|
+
default: true,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!useGlobalDefaults) {
|
|
144
|
+
print.hint('Local overrides can be set anytime with gitwrit config.');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// register the directory in global watch list
|
|
148
|
+
const added = await addWatchPath({ type: 'directory', path: dir });
|
|
149
|
+
|
|
150
|
+
print.gap();
|
|
151
|
+
|
|
152
|
+
if (!added) {
|
|
153
|
+
print.warn(`${dir} is already in your watch list.`);
|
|
154
|
+
print.gap();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── confirmation summary ───────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
const config = await loadGlobalConfig();
|
|
161
|
+
|
|
162
|
+
print.brand(isFirstRun ? 'All set.' : `${dir} added.`);
|
|
163
|
+
print.gap();
|
|
164
|
+
print.row('Watching', dir);
|
|
165
|
+
print.row('Files', config.fileTypes.join(', '));
|
|
166
|
+
print.row('Branch', config.branchMode);
|
|
167
|
+
print.row('Committing', `${config.debounce / 1000}s after last save`);
|
|
168
|
+
print.row('Pushing', `Every ${config.pushInterval / 60000} min`);
|
|
169
|
+
print.gap();
|
|
170
|
+
print.hint("Run gitwrit start whenever you're ready.");
|
|
171
|
+
print.gap();
|
|
172
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { print } from '../ui.js';
|
|
3
|
+
import { tailLog } from '../logger.js';
|
|
4
|
+
|
|
5
|
+
function colorize(line) {
|
|
6
|
+
if (line.includes(' commit ')) return chalk.green(line);
|
|
7
|
+
if (line.includes(' push ')) return chalk.blue(line);
|
|
8
|
+
if (line.includes(' error ')) return chalk.red(line);
|
|
9
|
+
if (line.includes(' warn ')) return chalk.yellow(line);
|
|
10
|
+
if (line.includes(' pause ')) return chalk.dim(line);
|
|
11
|
+
if (line.includes(' resume ')) return chalk.cyan(line);
|
|
12
|
+
return chalk.dim(line);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function logs(n = 20) {
|
|
16
|
+
const lines = await tailLog(n);
|
|
17
|
+
|
|
18
|
+
print.gap();
|
|
19
|
+
print.brand(`gitwrit log — last ${n} entries`);
|
|
20
|
+
print.gap();
|
|
21
|
+
|
|
22
|
+
if (lines.length === 0) {
|
|
23
|
+
print.hint('No activity yet. Run gitwrit start to begin.');
|
|
24
|
+
print.gap();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
console.log(' ' + colorize(line));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
print.gap();
|
|
33
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { cwd } from 'process';
|
|
4
|
+
import { print } from '../ui.js';
|
|
5
|
+
import { removeWatchPath, globalConfigExists } from '../settings.js';
|
|
6
|
+
|
|
7
|
+
export async function remove(dirArg) {
|
|
8
|
+
const dir = resolve(dirArg || cwd());
|
|
9
|
+
|
|
10
|
+
if (!(await globalConfigExists())) {
|
|
11
|
+
print.gap();
|
|
12
|
+
print.bad('gitwrit is not set up yet.');
|
|
13
|
+
print.gap();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
print.gap();
|
|
18
|
+
|
|
19
|
+
const confirmed = await confirm({
|
|
20
|
+
message: `Remove ${dir} from your watch list?`,
|
|
21
|
+
default: false,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!confirmed) {
|
|
25
|
+
print.hint('No changes made.');
|
|
26
|
+
print.gap();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const removed = await removeWatchPath(dir);
|
|
31
|
+
|
|
32
|
+
if (!removed) {
|
|
33
|
+
print.warn(`${dir} was not in your watch list.`);
|
|
34
|
+
} else {
|
|
35
|
+
print.good('Done.');
|
|
36
|
+
print.hint('Run gitwrit restart to apply.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
print.gap();
|
|
40
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { writeFile, mkdir, readFile } from 'fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
|
|
6
|
+
import { print } from '../ui.js';
|
|
7
|
+
import { PID_FILE, GITWRIT_DIR } from '../paths.js';
|
|
8
|
+
import { globalConfigExists, loadGlobalConfig } from '../settings.js';
|
|
9
|
+
import { readState, STATE } from '../state.js';
|
|
10
|
+
import { generateBranchName } from '../expansions.js';
|
|
11
|
+
|
|
12
|
+
const DAEMON_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), '../daemon.js');
|
|
13
|
+
|
|
14
|
+
// ─── random start messages ────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const START_MESSAGES = [
|
|
17
|
+
'Type all you want, we\'ve got you.',
|
|
18
|
+
'Happy writing!',
|
|
19
|
+
'Feel free to type away!',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function randomStartMessage() {
|
|
23
|
+
return START_MESSAGES[Math.floor(Math.random() * START_MESSAGES.length)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── stale pid check ──────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
async function getLivePid() {
|
|
29
|
+
try {
|
|
30
|
+
const raw = await readFile(PID_FILE, 'utf8');
|
|
31
|
+
const pid = parseInt(raw.trim(), 10);
|
|
32
|
+
if (isNaN(pid)) return null;
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return pid;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── main ─────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export async function start() {
|
|
43
|
+
if (!(await globalConfigExists())) {
|
|
44
|
+
print.gap();
|
|
45
|
+
print.bad('gitwrit is not set up yet.');
|
|
46
|
+
print.hint('Run gitwrit init in a directory to get started.');
|
|
47
|
+
print.gap();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const config = await loadGlobalConfig();
|
|
52
|
+
|
|
53
|
+
if (config.watch.length === 0) {
|
|
54
|
+
print.gap();
|
|
55
|
+
print.bad('No directories registered.');
|
|
56
|
+
print.hint('Run gitwrit init in a directory to register it.');
|
|
57
|
+
print.gap();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const livePid = await getLivePid();
|
|
62
|
+
if (livePid) {
|
|
63
|
+
print.gap();
|
|
64
|
+
print.bad(`gitwrit is already running. (PID ${livePid})`);
|
|
65
|
+
print.hint('Use gitwrit stop to stop it first.');
|
|
66
|
+
print.gap();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const state = await readState();
|
|
71
|
+
const wasUnclean = state.status === STATE.PAUSED || state.status === STATE.RUNNING;
|
|
72
|
+
|
|
73
|
+
let sessionBranch = null;
|
|
74
|
+
if (config.branchMode === 'autogenerated') {
|
|
75
|
+
sessionBranch = await generateBranchName();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await mkdir(GITWRIT_DIR, { recursive: true });
|
|
79
|
+
|
|
80
|
+
const daemon = spawn(process.execPath, [DAEMON_SCRIPT], {
|
|
81
|
+
detached: true,
|
|
82
|
+
stdio: 'ignore',
|
|
83
|
+
env: {
|
|
84
|
+
...process.env,
|
|
85
|
+
GITWRIT_SESSION_BRANCH: sessionBranch || '',
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
daemon.unref();
|
|
90
|
+
|
|
91
|
+
await writeFile(PID_FILE, String(daemon.pid), 'utf8');
|
|
92
|
+
|
|
93
|
+
await new Promise(r => setTimeout(r, 300));
|
|
94
|
+
|
|
95
|
+
print.gap();
|
|
96
|
+
|
|
97
|
+
if (wasUnclean) {
|
|
98
|
+
print.brand('gitwrit is running — picked up where you left off.');
|
|
99
|
+
} else {
|
|
100
|
+
print.brand('gitwrit is running.');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
print.gap();
|
|
104
|
+
print.row('Watching', config.watch.map(w => w.path).join(', '));
|
|
105
|
+
print.row('Files', config.fileTypes.join(', '));
|
|
106
|
+
|
|
107
|
+
if (sessionBranch) {
|
|
108
|
+
print.row('Branch', sessionBranch, 'autogenerated');
|
|
109
|
+
} else {
|
|
110
|
+
print.row('Branch', 'Current branch');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
print.row('Committing', `${config.debounce / 1000}s after last save`);
|
|
114
|
+
print.row('Pushing', `Every ${config.pushInterval / 60000} min`);
|
|
115
|
+
print.gap();
|
|
116
|
+
print.hint(randomStartMessage());
|
|
117
|
+
print.gap();
|
|
118
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { print, timeAgo } from '../ui.js';
|
|
3
|
+
import { PID_FILE } from '../paths.js';
|
|
4
|
+
import { readState, STATE } from '../state.js';
|
|
5
|
+
import { loadGlobalConfig, globalConfigExists } from '../settings.js';
|
|
6
|
+
|
|
7
|
+
async function getLivePid() {
|
|
8
|
+
try {
|
|
9
|
+
const raw = await readFile(PID_FILE, 'utf8');
|
|
10
|
+
const pid = parseInt(raw.trim(), 10);
|
|
11
|
+
process.kill(pid, 0);
|
|
12
|
+
return pid;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatUptime(startedAt) {
|
|
19
|
+
if (!startedAt) return 'Unknown';
|
|
20
|
+
const seconds = Math.floor((Date.now() - new Date(startedAt)) / 1000);
|
|
21
|
+
if (seconds < 60) return `${seconds}s`;
|
|
22
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
|
23
|
+
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function status() {
|
|
27
|
+
if (!(await globalConfigExists())) {
|
|
28
|
+
print.gap();
|
|
29
|
+
print.brand('gitwrit is not set up.');
|
|
30
|
+
print.hint('Run gitwrit init to get started.');
|
|
31
|
+
print.gap();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const pid = await getLivePid();
|
|
36
|
+
const state = await readState();
|
|
37
|
+
const config = await loadGlobalConfig();
|
|
38
|
+
|
|
39
|
+
print.gap();
|
|
40
|
+
|
|
41
|
+
if (!pid) {
|
|
42
|
+
if (state.status === STATE.PAUSED) {
|
|
43
|
+
print.brand('gitwrit is paused.');
|
|
44
|
+
print.gap();
|
|
45
|
+
print.info(`Resumed from sleep — last active ${timeAgo(state.pausedAt)}.`);
|
|
46
|
+
print.gap();
|
|
47
|
+
print.hint('Run gitwrit start to resume watching.');
|
|
48
|
+
} else {
|
|
49
|
+
print.brand('gitwrit is not running.');
|
|
50
|
+
print.hint('Run gitwrit start to begin watching.');
|
|
51
|
+
}
|
|
52
|
+
print.gap();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const resumedAfterSleep = state.resumedAt &&
|
|
57
|
+
(Date.now() - new Date(state.resumedAt)) < 60000;
|
|
58
|
+
|
|
59
|
+
if (resumedAfterSleep) {
|
|
60
|
+
print.brand('gitwrit is running — resumed after sleep.');
|
|
61
|
+
} else {
|
|
62
|
+
print.brand('gitwrit is running.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
print.gap();
|
|
66
|
+
print.row('Uptime', formatUptime(state.startedAt));
|
|
67
|
+
print.row('Watching', config.watch.map(w => w.path).join(', '));
|
|
68
|
+
|
|
69
|
+
if (state.sessionBranch) {
|
|
70
|
+
print.row('Branch', state.sessionBranch, 'autogenerated');
|
|
71
|
+
} else {
|
|
72
|
+
print.row('Branch', 'Current branch');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (state.sessionCommits > 0) {
|
|
76
|
+
print.row('Commits', `${state.sessionCommits} this session`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (state.lastCommittedAt) {
|
|
80
|
+
const fileLabel = state.lastCommittedFile ? ` (${state.lastCommittedFile})` : '';
|
|
81
|
+
print.row('Last commit', `${timeAgo(state.lastCommittedAt)}${fileLabel}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (state.lastPushedAt) {
|
|
85
|
+
print.row('Last push', timeAgo(state.lastPushedAt));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (resumedAfterSleep && state.catchUpCommits > 0) {
|
|
89
|
+
print.gap();
|
|
90
|
+
print.good(`${state.catchUpCommits} file${state.catchUpCommits !== 1 ? 's' : ''} committed on wake.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
print.gap();
|
|
94
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFile, unlink } from 'fs/promises';
|
|
2
|
+
import { print, timeAgo } from '../ui.js';
|
|
3
|
+
import { PID_FILE } from '../paths.js';
|
|
4
|
+
import { readState, markStopped } from '../state.js';
|
|
5
|
+
|
|
6
|
+
export async function stop() {
|
|
7
|
+
let pid;
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const raw = await readFile(PID_FILE, 'utf8');
|
|
11
|
+
pid = parseInt(raw.trim(), 10);
|
|
12
|
+
} catch {
|
|
13
|
+
print.gap();
|
|
14
|
+
print.bad('gitwrit is not running.');
|
|
15
|
+
print.gap();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
process.kill(pid, 0);
|
|
21
|
+
} catch {
|
|
22
|
+
await unlink(PID_FILE).catch(() => {});
|
|
23
|
+
await markStopped();
|
|
24
|
+
print.gap();
|
|
25
|
+
print.bad('gitwrit is not running.');
|
|
26
|
+
print.gap();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const state = await readState();
|
|
31
|
+
|
|
32
|
+
process.kill(pid, 'SIGTERM');
|
|
33
|
+
await new Promise(r => setTimeout(r, 500));
|
|
34
|
+
|
|
35
|
+
await unlink(PID_FILE).catch(() => {});
|
|
36
|
+
await markStopped();
|
|
37
|
+
|
|
38
|
+
print.gap();
|
|
39
|
+
print.brand('gitwrit stopped.');
|
|
40
|
+
print.gap();
|
|
41
|
+
|
|
42
|
+
if (state.sessionCommits > 0) {
|
|
43
|
+
print.row('Committed', `${state.sessionCommits} time${state.sessionCommits !== 1 ? 's' : ''} this session`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (state.lastPushedAt) {
|
|
47
|
+
print.row('Last push', timeAgo(state.lastPushedAt));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (state.sessionBranch) {
|
|
51
|
+
print.row('Branch', state.sessionBranch);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
print.gap();
|
|
55
|
+
print.hint('Your work is safe. See you next time.');
|
|
56
|
+
print.gap();
|
|
57
|
+
}
|
package/src/daemon.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { loadGlobalConfig } from './settings.js';
|
|
2
|
+
import { createWatcher } from './watcher.js';
|
|
3
|
+
import { createScheduler } from './scheduler.js';
|
|
4
|
+
import { commitFile, commitAll, createAndCheckoutBranch, branchExists } from './git.js';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
import { markRunning, markPaused, markStopped, readState, writeState } from './state.js';
|
|
7
|
+
import { unlink } from 'fs/promises';
|
|
8
|
+
import { PID_FILE } from './paths.js';
|
|
9
|
+
|
|
10
|
+
// ─── session ──────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const SESSION_BRANCH = process.env.GITWRIT_SESSION_BRANCH || null;
|
|
13
|
+
|
|
14
|
+
// ─── main ─────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
const config = await loadGlobalConfig();
|
|
18
|
+
const paths = config.watch.map(w => w.path);
|
|
19
|
+
|
|
20
|
+
await markRunning({
|
|
21
|
+
startedAt: new Date().toISOString(),
|
|
22
|
+
sessionBranch: SESSION_BRANCH,
|
|
23
|
+
sessionCommits: 0,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await logger.info('daemon started');
|
|
27
|
+
|
|
28
|
+
// ── branch setup ────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
if (SESSION_BRANCH) {
|
|
31
|
+
for (const dir of paths) {
|
|
32
|
+
const exists = await branchExists(dir, SESSION_BRANCH);
|
|
33
|
+
if (!exists) {
|
|
34
|
+
await createAndCheckoutBranch(dir, SESSION_BRANCH);
|
|
35
|
+
await logger.info(`created branch ${SESSION_BRANCH} in ${dir}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── scheduler ───────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const scheduler = createScheduler({
|
|
43
|
+
interval: config.pushInterval,
|
|
44
|
+
onPushComplete: async (dir) => {
|
|
45
|
+
await logger.push(`pushed ${dir}`);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ── commit handler ──────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
async function handleSave(filepath, dir) {
|
|
52
|
+
const shortPath = filepath.replace(dir, '').replace(/^\//, '');
|
|
53
|
+
const message = config.commitMessage
|
|
54
|
+
? config.commitMessage
|
|
55
|
+
.replace('{filename}', shortPath)
|
|
56
|
+
.replace('{timestamp}', new Date().toISOString())
|
|
57
|
+
: `auto: ${shortPath}`;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const committed = await commitFile(dir, filepath, message);
|
|
61
|
+
if (!committed) return; // nothing staged — file was already clean
|
|
62
|
+
|
|
63
|
+
await logger.commit(`${shortPath} → ${SESSION_BRANCH || 'current branch'}`);
|
|
64
|
+
|
|
65
|
+
const state = await readState();
|
|
66
|
+
await writeState({
|
|
67
|
+
sessionCommits: (state.sessionCommits || 0) + 1,
|
|
68
|
+
lastCommittedAt: new Date().toISOString(),
|
|
69
|
+
lastCommittedFile: shortPath,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
scheduler.queue(dir);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
await logger.error(`commit failed: ${filepath} — ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── watcher ─────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
let watcher = createWatcher({
|
|
81
|
+
paths,
|
|
82
|
+
fileTypes: config.fileTypes,
|
|
83
|
+
debounce: config.debounce,
|
|
84
|
+
onSave: handleSave,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await logger.info(`watching ${paths.join(', ')}`);
|
|
88
|
+
|
|
89
|
+
// ── signal: graceful stop (SIGTERM) ─────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
process.on('SIGTERM', async () => {
|
|
92
|
+
await logger.info('daemon stopped');
|
|
93
|
+
watcher.close();
|
|
94
|
+
scheduler.stop();
|
|
95
|
+
await markStopped();
|
|
96
|
+
await unlink(PID_FILE).catch(() => {});
|
|
97
|
+
process.exit(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ── signal: sleep/wake (SIGCONT) ─────────────────────────────────────────────
|
|
101
|
+
// sent by the OS when the machine wakes from sleep.
|
|
102
|
+
// chokidar watchers can go stale after a long sleep so we restart them
|
|
103
|
+
// and do a catch-up commit of anything that changed while we were out.
|
|
104
|
+
|
|
105
|
+
process.on('SIGCONT', async () => {
|
|
106
|
+
await logger.resume('woke from sleep — running catch-up scan');
|
|
107
|
+
|
|
108
|
+
watcher.close();
|
|
109
|
+
|
|
110
|
+
// catch-up: commit any dirty files across all watched dirs
|
|
111
|
+
let totalCaught = 0;
|
|
112
|
+
for (const dir of paths) {
|
|
113
|
+
try {
|
|
114
|
+
const count = await commitAll(dir, 'auto: catch-up after sleep');
|
|
115
|
+
totalCaught += count;
|
|
116
|
+
if (count > 0) {
|
|
117
|
+
await logger.commit(`catch-up: ${count} file${count !== 1 ? 's' : ''} in ${dir}`);
|
|
118
|
+
scheduler.queue(dir);
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
await logger.error(`catch-up failed in ${dir}: ${err.message}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await writeState({
|
|
126
|
+
resumedAt: new Date().toISOString(),
|
|
127
|
+
catchUpCommits: totalCaught,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// restart watchers fresh
|
|
131
|
+
watcher = createWatcher({
|
|
132
|
+
paths,
|
|
133
|
+
fileTypes: config.fileTypes,
|
|
134
|
+
debounce: config.debounce,
|
|
135
|
+
onSave: handleSave,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// push anything that accumulated
|
|
139
|
+
await scheduler.flushNow();
|
|
140
|
+
|
|
141
|
+
await logger.resume(`resumed — ${totalCaught} file${totalCaught !== 1 ? 's' : ''} caught up`);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── signal: pause (SIGTSTP) ──────────────────────────────────────────────────
|
|
145
|
+
// sent when the user suspends the process (Ctrl+Z) — shouldn't happen to
|
|
146
|
+
// a detached daemon, but handle it gracefully just in case.
|
|
147
|
+
|
|
148
|
+
process.on('SIGTSTP', async () => {
|
|
149
|
+
await markPaused();
|
|
150
|
+
await logger.pause('daemon paused');
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
main().catch(async (err) => {
|
|
155
|
+
await logger.error(`daemon crashed: ${err.message}`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
});
|