total-recall-brain 3.40.0 → 3.40.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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli/backup.mjs +71 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.40.0",
3
+ "version": "3.40.1",
4
4
  "description": "Portable SSSS memory, local retrieval and task instructions with explicit plugin loading.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { spawnSync } from 'node:child_process';
20
+ import crypto from 'node:crypto';
20
21
  import fs from 'node:fs';
21
22
  import os from 'node:os';
22
23
  import path from 'node:path';
@@ -64,8 +65,22 @@ function parseArgs(args) {
64
65
  return opts;
65
66
  }
66
67
 
68
+ /**
69
+ * The backup repo's git directory lives OUTSIDE the project tree, under
70
+ * ~/.agent/brain-backups. A `.git` inside `<project>/.agent/skills/total-recall`
71
+ * makes the project repo see an embedded repository: `git add -A` records it as a
72
+ * gitlink (mode 160000) with no .gitmodules, which cannot be cloned back.
73
+ */
74
+ export function brainGitDir(skillDir) {
75
+ const abs = path.resolve(skillDir);
76
+ const owner = path.basename(path.resolve(abs, '..', '..', '..')) || 'brain';
77
+ const tag = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 8);
78
+ return path.join(os.homedir(), '.agent', 'brain-backups', `${owner}-${tag}.git`);
79
+ }
80
+
67
81
  function git(cwd, ...gitArgs) {
68
- const result = spawnSync('git', gitArgs, { cwd, stdio: 'pipe' });
82
+ const env = { ...process.env, GIT_DIR: brainGitDir(cwd), GIT_WORK_TREE: path.resolve(cwd) };
83
+ const result = spawnSync('git', gitArgs, { cwd, env, stdio: 'pipe' });
69
84
  return {
70
85
  ok: result.status === 0,
71
86
  stdout: result.stdout?.toString().trim() || '',
@@ -73,6 +88,56 @@ function git(cwd, ...gitArgs) {
73
88
  };
74
89
  }
75
90
 
91
+ /**
92
+ * Move a legacy in-tree `<brain>/.git` to the external git dir, and keep the
93
+ * enclosing project repo from absorbing the brain directory. Idempotent.
94
+ */
95
+ export function adoptExternalGitDir(skillDir) {
96
+ const inTree = path.join(skillDir, '.git');
97
+ const external = brainGitDir(skillDir);
98
+ let moved = false;
99
+ if (fs.existsSync(inTree) && fs.statSync(inTree).isDirectory() && !fs.existsSync(external)) {
100
+ fs.mkdirSync(path.dirname(external), { recursive: true });
101
+ try {
102
+ fs.renameSync(inTree, external);
103
+ } catch {
104
+ fs.cpSync(inTree, external, { recursive: true });
105
+ fs.rmSync(inTree, { recursive: true, force: true });
106
+ }
107
+ spawnSync('git', ['--git-dir', external, 'config', 'core.worktree', path.resolve(skillDir)], { stdio: 'pipe' });
108
+ moved = true;
109
+ }
110
+ if (fs.existsSync(external) && !fs.existsSync(inTree)) excludeFromParentRepo(skillDir);
111
+ return moved;
112
+ }
113
+
114
+ /** Add the brain dir to the enclosing repo's local exclude unless that repo tracks it on purpose. */
115
+ function excludeFromParentRepo(skillDir) {
116
+ try {
117
+ const top = spawnSync('git', ['-C', skillDir, 'rev-parse', '--show-toplevel'], { stdio: 'pipe' });
118
+ if (top.status !== 0) return;
119
+ const root = top.stdout.toString().trim();
120
+ const rel = path.relative(fs.realpathSync(root), fs.realpathSync(skillDir)).split(path.sep).join('/');
121
+ if (!rel || rel.startsWith('..')) return;
122
+ const tracked = spawnSync('git', ['-C', root, 'ls-files', '-s', '--', rel], { stdio: 'pipe' }).stdout.toString().trim();
123
+ if (/^160000 /.test(tracked) && !tracked.includes('\n')) {
124
+ // A stale gitlink (embedded repo with no .gitmodules) cannot be cloned back: unstage it.
125
+ spawnSync('git', ['-C', root, 'rm', '--cached', '-f', '-q', '--', rel], { stdio: 'pipe' });
126
+ } else if (tracked) {
127
+ return; // the project repo versions its brain files itself
128
+ }
129
+ const excludePath = path.join(root, '.git', 'info', 'exclude');
130
+ try {
131
+ const entry = `/${rel}/`;
132
+ const current = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, 'utf8') : '';
133
+ if (!current.split('\n').includes(entry)) {
134
+ fs.mkdirSync(path.dirname(excludePath), { recursive: true });
135
+ fs.appendFileSync(excludePath, `${current && !current.endsWith('\n') ? '\n' : ''}${entry}\n`);
136
+ }
137
+ } catch { /* parent exclude is best effort */ }
138
+ } catch { /* best effort: never fail a backup over the parent's exclude file */ }
139
+ }
140
+
76
141
  function defaultOutput(encrypt) {
77
142
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
78
143
  const suffix = encrypt ? '.tar.gz.gpg' : '.tar.gz';
@@ -169,8 +234,10 @@ async function pushGitBackup(remote, layer = 'auto') {
169
234
  // rule are corrected on their next backup.
170
235
  ensureBrainGitignore(SKILL_DIR);
171
236
 
172
- // Initialise the skill folder as a git repo if needed
173
- const gitDir = path.join(SKILL_DIR, '.git');
237
+ // Initialise the skill folder as a git repo if needed. The git dir lives outside
238
+ // the project tree; a legacy in-tree .git is moved there first.
239
+ adoptExternalGitDir(SKILL_DIR);
240
+ const gitDir = brainGitDir(SKILL_DIR);
174
241
  if (!fs.existsSync(gitDir)) {
175
242
  console.error(` 🔧 Initialising git repo in ${SKILL_DIR}`);
176
243
  const init = git(SKILL_DIR, 'init', '-b', 'main');
@@ -178,6 +245,7 @@ async function pushGitBackup(remote, layer = 'auto') {
178
245
  git(SKILL_DIR, 'init');
179
246
  git(SKILL_DIR, 'checkout', '-b', 'main');
180
247
  }
248
+ excludeFromParentRepo(SKILL_DIR);
181
249
  }
182
250
 
183
251
  // Resolve the remote: if the argument is an existing remote name, use its URL.