@harryisfish/gitt 1.4.1 โ†’ 1.6.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/README.md CHANGED
@@ -18,8 +18,10 @@ pnpm add -g @harryisfish/gitt
18
18
 
19
19
  ## Usage
20
20
 
21
+ ### Basic Commands
22
+
21
23
  ```bash
22
- # Default behavior (auto-clean)
24
+ # Default behavior (auto-clean deleted branches)
23
25
  gitt
24
26
 
25
27
  # Interactive mode (select branches to delete)
@@ -31,8 +33,63 @@ gitt --interactive
31
33
  gitt -d
32
34
  # or
33
35
  gitt --dry-run
36
+
37
+ # Check version
38
+ gitt -v
39
+ # or
40
+ gitt --version
41
+
42
+ # Show help
43
+ gitt -h
44
+ # or
45
+ gitt --help
46
+ ```
47
+
48
+ ### Stale Branch Cleaning (NEW)
49
+
50
+ Clean up branches that haven't been updated for a long time:
51
+
52
+ ```bash
53
+ # Find branches inactive for 90+ days (default)
54
+ gitt --stale
55
+
56
+ # Custom threshold (e.g., 30 days)
57
+ gitt --stale 30
58
+
59
+ # Preview stale branches without deleting
60
+ gitt --stale --dry-run
61
+
62
+ # Interactively select stale branches to delete
63
+ gitt --stale -i
34
64
  ```
35
65
 
66
+ **Note:** Stale branch detection:
67
+ - Checks the last commit date on each branch
68
+ - Automatically excludes the main branch
69
+ - Protects branches in use by Git worktrees
70
+ - Can be combined with interactive (`-i`) and dry-run (`-d`) modes
71
+
72
+
73
+ ## Features
74
+
75
+ ### ๐Ÿ”„ Auto Update Notification
76
+ Gitt automatically checks for updates on each run and notifies you when a new version is available.
77
+
78
+ ### ๐ŸŒณ Worktree Protection
79
+ Branches currently checked out in Git worktrees are automatically protected from deletion to prevent errors.
80
+
81
+ ### ๐Ÿงน Smart Branch Cleanup
82
+ - **Remote-deleted branches**: Automatically detect and clean branches removed from remote
83
+ - **Stale branches**: Find and remove branches inactive for X days
84
+ - **Merge status check**: Safely handles both merged and unmerged branches
85
+ - **Interactive mode**: Manual selection with clear indicators
86
+ - **Dry-run mode**: Preview changes before applying
87
+
88
+ ### ๐Ÿ›ก๏ธ Branch Protection
89
+ - Honors `.gitt` ignore patterns
90
+ - Respects Git worktree usage
91
+ - Never touches the main branch
92
+
36
93
  ## Configuration
37
94
 
38
95
  ### Main Branch Detection
@@ -6,7 +6,6 @@ const listr2_1 = require("listr2");
6
6
  const prompts_1 = require("@inquirer/prompts");
7
7
  const minimatch_1 = require("minimatch");
8
8
  const errors_1 = require("../errors");
9
- const errors_2 = require("../errors");
10
9
  const git_1 = require("../utils/git");
11
10
  const config_1 = require("../utils/config");
12
11
  const git = (0, simple_git_1.simpleGit)();
@@ -33,81 +32,141 @@ async function cleanDeletedBranches(options = {}) {
33
32
  {
34
33
  title: 'Switch to main branch',
35
34
  task: async (ctx) => {
36
- await git.checkout(ctx.mainBranch);
35
+ const currentBranch = (await git.branchLocal()).current;
36
+ const willDeleteCurrent = state.deletedBranches.some(b => b.name === currentBranch);
37
+ try {
38
+ await git.checkout(ctx.mainBranch);
39
+ }
40
+ catch (e) {
41
+ // If current branch will be deleted, we must switch - fail the operation
42
+ if (willDeleteCurrent) {
43
+ throw new errors_1.GitError(`Cannot switch to ${ctx.mainBranch}. Current branch "${currentBranch}" will be deleted but checkout failed.`);
44
+ }
45
+ // Otherwise, warn but continue (we can delete other branches)
46
+ console.warn(`Warning: Could not switch to ${ctx.mainBranch}, but continuing as current branch is not being deleted.`);
47
+ }
37
48
  }
38
49
  },
39
50
  {
40
51
  title: 'Sync main with remote',
41
52
  task: async () => {
42
- await git.pull();
53
+ try {
54
+ await git.pull();
55
+ }
56
+ catch (e) {
57
+ // Ignore pull errors (e.g. if not on branch)
58
+ }
43
59
  }
44
60
  },
45
61
  {
46
- title: 'Fetch and prune remote branches',
62
+ title: 'Analyze branches',
47
63
  task: async (ctx) => {
48
64
  await git.fetch(['--prune']);
49
65
  const branchSummary = await git.branch(['-vv']);
50
66
  const config = await (0, config_1.readConfigFile)();
51
67
  const ignorePatterns = config.ignoreBranches || [];
52
- let deletedBranches = branchSummary.all.filter(branch => {
53
- const branchInfo = branchSummary.branches[branch];
54
- return branchInfo.label && branchInfo.label.includes(': gone]');
55
- });
68
+ const worktreeBranches = await (0, git_1.getWorktrees)();
69
+ let candidates = [];
70
+ if (options.stale) {
71
+ // Stale mode: check all local branches
72
+ const allBranches = branchSummary.all;
73
+ for (const branch of allBranches) {
74
+ if (branch === ctx.mainBranch)
75
+ continue;
76
+ const days = await (0, git_1.getBranchLastCommitTime)(branch);
77
+ if (days > (options.staleDays || 90)) {
78
+ candidates.push({
79
+ name: branch,
80
+ reason: `Stale (${days} days)`
81
+ });
82
+ }
83
+ }
84
+ }
85
+ else {
86
+ // Default mode: check "gone" branches
87
+ const goneBranches = branchSummary.all.filter(branch => {
88
+ const branchInfo = branchSummary.branches[branch];
89
+ return branchInfo.label && branchInfo.label.includes(': gone]');
90
+ });
91
+ candidates = goneBranches.map(b => ({ name: b, reason: 'Remote deleted' }));
92
+ }
56
93
  // Filter out ignored branches
57
94
  if (ignorePatterns.length > 0) {
58
- deletedBranches = deletedBranches.filter(branch => {
59
- const isIgnored = ignorePatterns.some(pattern => (0, minimatch_1.minimatch)(branch, pattern));
95
+ candidates = candidates.filter(c => {
96
+ const isIgnored = ignorePatterns.some(pattern => (0, minimatch_1.minimatch)(c.name, pattern));
60
97
  return !isIgnored;
61
98
  });
62
99
  }
63
- ctx.deletedBranches = deletedBranches;
100
+ // Filter out worktree branches
101
+ candidates = candidates.filter(c => !worktreeBranches.includes(c.name));
102
+ // Check merge status for each branch
103
+ const branchesWithStatus = await Promise.all(candidates.map(async (c) => {
104
+ const isMerged = await (0, git_1.isBranchMerged)(c.name, ctx.mainBranch);
105
+ return { ...c, isMerged };
106
+ }));
107
+ ctx.deletedBranches = branchesWithStatus;
64
108
  }
65
109
  }
66
110
  ]);
67
111
  await discoveryTasks.run(state);
68
112
  // Phase 2: Interaction / Filtering
69
113
  if (state.deletedBranches.length === 0) {
70
- (0, errors_2.printSuccess)('No branches need to be cleaned up');
114
+ (0, errors_1.printSuccess)('No branches need to be cleaned up');
71
115
  return;
72
116
  }
73
117
  if (options.interactive) {
74
118
  try {
119
+ const choices = state.deletedBranches.map(b => ({
120
+ name: `${b.name} (${b.reason}${b.isMerged ? '' : ', Unmerged'})`,
121
+ value: b,
122
+ checked: b.isMerged // Only check merged branches by default
123
+ }));
75
124
  const selected = await (0, prompts_1.checkbox)({
76
125
  message: 'Select branches to delete:',
77
- choices: state.deletedBranches.map(b => ({ name: b, value: b, checked: true })),
126
+ choices: choices,
78
127
  });
79
128
  state.deletedBranches = selected;
80
129
  }
81
130
  catch (e) {
82
131
  // User cancelled
83
- throw new Error('Operation cancelled');
132
+ throw new errors_1.UserCancelError('Operation cancelled');
133
+ }
134
+ }
135
+ else {
136
+ // Auto mode: Filter out unmerged branches
137
+ const unmerged = state.deletedBranches.filter(b => !b.isMerged);
138
+ if (unmerged.length > 0) {
139
+ console.log('\nSkipping unmerged branches (use -i to force delete):');
140
+ unmerged.forEach(b => console.log(` - ${b.name} (${b.reason})`));
84
141
  }
142
+ state.deletedBranches = state.deletedBranches.filter(b => b.isMerged);
85
143
  }
86
144
  if (state.deletedBranches.length === 0) {
87
- (0, errors_2.printSuccess)('No branches selected for deletion');
145
+ (0, errors_1.printSuccess)('No branches selected for deletion');
88
146
  return;
89
147
  }
90
148
  if (options.dryRun) {
91
149
  console.log('\nDry Run: The following branches would be deleted:');
92
- state.deletedBranches.forEach(b => console.log(` - ${b}`));
150
+ state.deletedBranches.forEach(b => console.log(` - ${b.name} (${b.reason})`));
93
151
  return;
94
152
  }
95
153
  // Phase 3: Execution
96
154
  const deleteTasks = new listr2_1.Listr([
97
155
  {
98
- title: 'Delete branches removed on remote',
99
- task: (ctx) => {
156
+ title: 'Delete branches',
157
+ task: (_ctx) => {
100
158
  return new listr2_1.Listr(state.deletedBranches.map(branch => ({
101
- title: `Delete ${branch}`,
159
+ title: `Delete ${branch.name}`,
102
160
  task: async () => {
103
- await git.branch(['-D', branch]);
161
+ // Always use -D to force delete if we are here (user confirmed or it's merged)
162
+ await git.branch(['-D', branch.name]);
104
163
  }
105
164
  })), { concurrent: false });
106
165
  }
107
166
  }
108
167
  ]);
109
168
  await deleteTasks.run(state);
110
- (0, errors_2.printSuccess)('Branch cleanup completed');
169
+ (0, errors_1.printSuccess)('Branch cleanup completed');
111
170
  }
112
171
  catch (error) {
113
172
  throw new errors_1.GitError(error instanceof Error ? error.message : 'Unknown error occurred while cleaning branches');
package/dist/errors.js CHANGED
@@ -1,9 +1,13 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.UserCancelError = exports.GitError = void 0;
4
7
  exports.handleError = handleError;
5
8
  exports.printSuccess = printSuccess;
6
9
  exports.printError = printError;
10
+ const chalk_1 = __importDefault(require("chalk"));
7
11
  // ่‡ชๅฎšไน‰้”™่ฏฏ็ฑปๅž‹
8
12
  class GitError extends Error {
9
13
  constructor(message) {
@@ -19,10 +23,6 @@ class UserCancelError extends Error {
19
23
  }
20
24
  }
21
25
  exports.UserCancelError = UserCancelError;
22
- // ้”™่ฏฏๆถˆๆฏ้ขœ่‰ฒ
23
- const ERROR_COLOR = '\x1b[31m';
24
- const SUCCESS_COLOR = '\x1b[32m';
25
- const RESET_COLOR = '\x1b[0m';
26
26
  // ็ปŸไธ€้”™่ฏฏๅค„็†ๅ‡ฝๆ•ฐ
27
27
  function handleError(error) {
28
28
  if (error instanceof UserCancelError) {
@@ -30,21 +30,21 @@ function handleError(error) {
30
30
  process.exit(0);
31
31
  }
32
32
  if (error instanceof GitError) {
33
- console.error(`${ERROR_COLOR}Error: ${RESET_COLOR}${error.message}`);
33
+ console.error(chalk_1.default.red('Error:'), error.message);
34
34
  process.exit(1);
35
35
  }
36
36
  if (error instanceof Error) {
37
- console.error(`${ERROR_COLOR}Program error: ${RESET_COLOR}${error.message}`);
37
+ console.error(chalk_1.default.red('Program error:'), error.message);
38
38
  process.exit(1);
39
39
  }
40
- console.error(`${ERROR_COLOR}Unknown error occurred${RESET_COLOR}`);
40
+ console.error(chalk_1.default.red('Unknown error occurred'));
41
41
  process.exit(1);
42
42
  }
43
43
  // ๆˆๅŠŸๆถˆๆฏๅค„็†
44
44
  function printSuccess(message) {
45
- console.log(`${SUCCESS_COLOR}โœ“ ${message}${RESET_COLOR}`);
45
+ console.log(chalk_1.default.green('โœ“'), message);
46
46
  }
47
47
  // ้”™่ฏฏๆถˆๆฏๅค„็†
48
48
  function printError(message) {
49
- console.error(`${ERROR_COLOR}${message}${RESET_COLOR}`);
49
+ console.error(chalk_1.default.red(message));
50
50
  }
package/dist/index.js CHANGED
@@ -34,10 +34,12 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  };
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
+ const commander_1 = require("commander");
37
38
  const simple_git_1 = require("simple-git");
38
39
  const errors_1 = require("./errors");
39
40
  const clean_1 = require("./commands/clean");
40
41
  const git = (0, simple_git_1.simpleGit)();
42
+ const packageJson = require('../package.json');
41
43
  // ๅค„็† Ctrl+C ๅ’Œๅ…ถไป–็ปˆๆญขไฟกๅท
42
44
  process.on('SIGINT', () => {
43
45
  throw new errors_1.UserCancelError('\nOperation cancelled');
@@ -45,16 +47,6 @@ process.on('SIGINT', () => {
45
47
  process.on('SIGTERM', () => {
46
48
  throw new errors_1.UserCancelError('\nProgram terminated');
47
49
  });
48
- // ๅˆๅง‹ๅŒ– Git ไป“ๅบ“
49
- async function initGitRepo() {
50
- try {
51
- await git.init();
52
- (0, errors_1.printSuccess)('Git repository initialized successfully');
53
- }
54
- catch (error) {
55
- throw new errors_1.GitError('Failed to initialize Git repository');
56
- }
57
- }
58
50
  // ๆฃ€ๆŸฅๅฝ“ๅ‰็›ฎๅฝ•ๆ˜ฏๅฆๆ˜ฏ Git ไป“ๅบ“
59
51
  async function checkGitRepo() {
60
52
  const isRepo = await git.checkIsRepo();
@@ -74,67 +66,70 @@ async function checkGitRepo() {
74
66
  throw new errors_1.GitError('Cannot access remote repository, please check network connection or repository permissions');
75
67
  }
76
68
  }
77
- function printHelp() {
78
- console.log(`
79
- Usage: gitt [command] [options]
80
-
81
- Commands:
82
- (default) Clean up local branches that have been deleted on remote
83
- set-main <branch> Set the main branch for the current project
84
- ignore <pattern> Add a branch pattern to the ignore list (e.g., "release/*")
85
-
86
- Options:
87
- -i, --interactive Interactive mode: Select branches to delete
88
- -d, --dry-run Dry run: Show what would be deleted without deleting
89
- -h, --help Show this help message
90
-
91
- Examples:
92
- gitt # Auto-clean deleted branches
93
- gitt -i # Select branches to delete interactively
94
- gitt -d # Preview deletion
95
- gitt ignore "temp/*" # Ignore branches matching "temp/*"
96
- gitt set-main master # Set main branch to 'master'
97
- `);
98
- }
99
69
  async function main() {
100
70
  try {
101
- const args = process.argv.slice(2);
102
- const command = args[0];
103
- // Check for help command
104
- if (args.includes('-h') || args.includes('--help')) {
105
- printHelp();
106
- process.exit(0);
71
+ // Check for updates before parsing commands
72
+ try {
73
+ // Use eval to prevent TypeScript from transpiling dynamic import to require()
74
+ const { default: updateNotifier } = await eval('import("update-notifier")');
75
+ updateNotifier({ pkg: packageJson }).notify();
107
76
  }
108
- // ๆฃ€ๆŸฅ Git ไป“ๅบ“
109
- await checkGitRepo();
110
- if (command === 'set-main') {
111
- const branch = args[1];
112
- if (!branch) {
113
- throw new Error('Please specify a branch name');
114
- }
115
- await Promise.resolve().then(() => __importStar(require('./commands/config'))).then(m => m.configMainBranch(branch));
77
+ catch (e) {
78
+ // Ignore update check errors
116
79
  }
117
- else if (command === 'ignore') {
118
- const pattern = args[1];
119
- if (!pattern) {
120
- throw new Error('Please specify a branch pattern to ignore');
121
- }
80
+ const program = new commander_1.Command();
81
+ program
82
+ .name('gitt')
83
+ .description('A CLI tool for Git branch management')
84
+ .version(packageJson.version, '-v, --version', 'Show version number');
85
+ // Default command: clean deleted branches
86
+ program
87
+ .option('-i, --interactive', 'Interactive mode: Select branches to delete')
88
+ .option('-d, --dry-run', 'Dry run: Show what would be deleted without deleting')
89
+ .option('--stale [days]', 'Find stale branches (default: 90 days)', '90')
90
+ .action(async (options) => {
91
+ await checkGitRepo();
92
+ const staleDays = options.stale === true ? 90 : parseInt(options.stale, 10);
93
+ const isStale = options.stale !== undefined;
94
+ await (0, clean_1.cleanDeletedBranches)({
95
+ interactive: options.interactive || false,
96
+ dryRun: options.dryRun || false,
97
+ stale: isStale,
98
+ staleDays: staleDays
99
+ });
100
+ });
101
+ // set-main command
102
+ program
103
+ .command('set-main <branch>')
104
+ .description('Set the main branch for the current project')
105
+ .action(async (branch) => {
106
+ await checkGitRepo();
107
+ await Promise.resolve().then(() => __importStar(require('./commands/config'))).then(m => m.configMainBranch(branch));
108
+ });
109
+ // ignore command
110
+ program
111
+ .command('ignore <pattern>')
112
+ .description('Add a branch pattern to the ignore list (e.g., "release/*")')
113
+ .action(async (pattern) => {
114
+ await checkGitRepo();
122
115
  await Promise.resolve().then(() => __importStar(require('./commands/config'))).then(m => m.configIgnoreBranch(pattern));
123
- }
124
- else {
125
- // Parse options
126
- const isInteractive = args.includes('-i') || args.includes('--interactive');
127
- const isDryRun = args.includes('-d') || args.includes('--dry-run');
128
- // ้ป˜่ฎคๆ‰ง่กŒๆธ…็†ๆ“ไฝœ
129
- await (0, clean_1.cleanDeletedBranches)({ interactive: isInteractive, dryRun: isDryRun });
130
- }
131
- // ้€€ๅ‡บ็จ‹ๅบ
132
- process.exit(0);
116
+ });
117
+ // Add examples to help
118
+ program.addHelpText('after', `
119
+ Examples:
120
+ $ gitt # Auto-clean deleted branches
121
+ $ gitt -i # Select branches to delete interactively
122
+ $ gitt -d # Preview deletion
123
+ $ gitt --stale # Find branches inactive for 90+ days
124
+ $ gitt --stale 30 # Find branches inactive for 30+ days
125
+ $ gitt ignore "temp/*" # Ignore branches matching "temp/*"
126
+ $ gitt set-main master # Set main branch to 'master'
127
+ `);
128
+ await program.parseAsync(process.argv);
133
129
  }
134
130
  catch (error) {
135
131
  (0, errors_1.handleError)(error);
136
- process.exit(1);
137
132
  }
138
133
  }
139
134
  // ๅฏๅŠจ็จ‹ๅบ
140
- main().catch(errors_1.handleError);
135
+ main();
@@ -39,7 +39,50 @@ exports.writeConfigFile = writeConfigFile;
39
39
  const fs = __importStar(require("fs"));
40
40
  const path = __importStar(require("path"));
41
41
  const simple_git_1 = require("simple-git");
42
+ const minimatch_1 = require("minimatch");
42
43
  const CONFIG_FILE_NAME = '.gitt';
44
+ /**
45
+ * Validate configuration values.
46
+ */
47
+ function validateConfig(config) {
48
+ const validated = {};
49
+ // Validate mainBranch
50
+ if (config.mainBranch !== undefined) {
51
+ if (typeof config.mainBranch !== 'string' || config.mainBranch.trim() === '') {
52
+ throw new Error('Invalid config: mainBranch must be a non-empty string');
53
+ }
54
+ validated.mainBranch = config.mainBranch.trim();
55
+ }
56
+ // Validate ignoreBranches
57
+ if (config.ignoreBranches !== undefined) {
58
+ if (!Array.isArray(config.ignoreBranches)) {
59
+ throw new Error('Invalid config: ignoreBranches must be an array');
60
+ }
61
+ const validPatterns = [];
62
+ for (const pattern of config.ignoreBranches) {
63
+ if (typeof pattern !== 'string' || pattern.trim() === '') {
64
+ throw new Error('Invalid config: ignoreBranches must contain non-empty strings');
65
+ }
66
+ // Test if it's a valid glob pattern by trying to use it
67
+ try {
68
+ (0, minimatch_1.minimatch)('test', pattern);
69
+ validPatterns.push(pattern);
70
+ }
71
+ catch (e) {
72
+ throw new Error(`Invalid config: "${pattern}" is not a valid glob pattern`);
73
+ }
74
+ }
75
+ validated.ignoreBranches = validPatterns;
76
+ }
77
+ // Validate staleDays
78
+ if (config.staleDays !== undefined) {
79
+ if (typeof config.staleDays !== 'number' || config.staleDays < 1 || config.staleDays > 365 || !Number.isInteger(config.staleDays)) {
80
+ throw new Error('Invalid config: staleDays must be an integer between 1 and 365');
81
+ }
82
+ validated.staleDays = config.staleDays;
83
+ }
84
+ return validated;
85
+ }
43
86
  /**
44
87
  * Get the project root directory (where .git is located).
45
88
  */
@@ -64,9 +107,15 @@ async function readConfigFile() {
64
107
  return {};
65
108
  }
66
109
  const content = fs.readFileSync(configPath, 'utf-8');
67
- return JSON.parse(content);
110
+ const parsedConfig = JSON.parse(content);
111
+ return validateConfig(parsedConfig);
68
112
  }
69
113
  catch (error) {
114
+ // If validation fails, throw the error instead of returning empty config
115
+ if (error instanceof Error && error.message.startsWith('Invalid config:')) {
116
+ throw error;
117
+ }
118
+ // For other errors (file read, JSON parse), return empty config
70
119
  return {};
71
120
  }
72
121
  }
@@ -74,10 +123,12 @@ async function readConfigFile() {
74
123
  * Write to the .gitt configuration file.
75
124
  */
76
125
  async function writeConfigFile(config) {
126
+ // Validate new config before writing
127
+ const validatedConfig = validateConfig(config);
77
128
  const root = await getProjectRoot();
78
129
  const configPath = path.join(root, CONFIG_FILE_NAME);
79
130
  // Read existing config to merge
80
131
  const currentConfig = await readConfigFile();
81
- const newConfig = { ...currentConfig, ...config };
132
+ const newConfig = { ...currentConfig, ...validatedConfig };
82
133
  fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2));
83
134
  }
package/dist/utils/git.js CHANGED
@@ -2,6 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getMainBranch = getMainBranch;
4
4
  exports.setMainBranch = setMainBranch;
5
+ exports.isBranchMerged = isBranchMerged;
6
+ exports.getWorktrees = getWorktrees;
7
+ exports.getBranchLastCommitTime = getBranchLastCommitTime;
5
8
  const simple_git_1 = require("simple-git");
6
9
  const errors_1 = require("../errors");
7
10
  const config_1 = require("./config");
@@ -74,3 +77,59 @@ async function setMainBranch(branch) {
74
77
  throw new errors_1.GitError('Failed to set main branch configuration');
75
78
  }
76
79
  }
80
+ /**
81
+ * Check if a branch is merged into the main branch.
82
+ */
83
+ async function isBranchMerged(branch, mainBranch) {
84
+ try {
85
+ const mergedBranches = await git.branch(['--merged', mainBranch]);
86
+ return mergedBranches.all.includes(branch);
87
+ }
88
+ catch (error) {
89
+ return false;
90
+ }
91
+ }
92
+ /**
93
+ * Get a list of branches that are currently checked out in worktrees.
94
+ */
95
+ async function getWorktrees() {
96
+ try {
97
+ // Output format:
98
+ // /path/to/repo (HEAD detached at 123456)
99
+ // /path/to/worktree [branch-name]
100
+ const worktrees = await git.raw(['worktree', 'list']);
101
+ const lines = worktrees.split('\n').filter(Boolean);
102
+ const branches = [];
103
+ for (const line of lines) {
104
+ // Extract branch name from [branch-name]
105
+ const match = line.match(/\[(.*?)\]/);
106
+ if (match && match[1]) {
107
+ branches.push(match[1]);
108
+ }
109
+ }
110
+ return branches;
111
+ }
112
+ catch (error) {
113
+ // If worktrees are not supported or error occurs, return empty list
114
+ return [];
115
+ }
116
+ }
117
+ /**
118
+ * Get the last commit time (in days) for a branch.
119
+ * Returns the number of days since the last commit.
120
+ */
121
+ async function getBranchLastCommitTime(branch) {
122
+ try {
123
+ // Get unix timestamp of last commit
124
+ const timestamp = await git.raw(['log', '-1', '--format=%at', branch]);
125
+ const lastCommitDate = new Date(parseInt(timestamp.trim()) * 1000);
126
+ const now = new Date();
127
+ const diffTime = Math.abs(now.getTime() - lastCommitDate.getTime());
128
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
129
+ return diffDays;
130
+ }
131
+ catch (error) {
132
+ // If branch doesn't exist or error, return 0 (treat as active/new to be safe)
133
+ return 0;
134
+ }
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harryisfish/gitt",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "A command-line tool to help you manage Git repositories and remote repositories, such as keeping in sync, pushing, pulling, etc.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -10,6 +10,9 @@
10
10
  "build": "tsc",
11
11
  "start": "tsx src/index.ts",
12
12
  "dev": "tsx watch src/index.ts",
13
+ "test": "vitest",
14
+ "test:ui": "vitest --ui",
15
+ "test:coverage": "vitest --coverage",
13
16
  "prepare": "pnpm run build",
14
17
  "dev:link": "pnpm build && pnpm link --global",
15
18
  "dev:unlink": "pnpm unlink --global"
@@ -32,16 +35,22 @@
32
35
  "homepage": "https://github.com/harryisfish/gitt#readme",
33
36
  "devDependencies": {
34
37
  "@types/node": "^20.11.24",
38
+ "@vitest/ui": "^4.0.14",
35
39
  "ts-node": "^10.9.2",
36
40
  "tsx": "^4.7.1",
37
- "typescript": "^5.3.3"
41
+ "typescript": "^5.3.3",
42
+ "vitest": "^4.0.14"
38
43
  },
39
44
  "dependencies": {
40
45
  "@inquirer/prompts": "^3.3.0",
41
46
  "@types/minimatch": "^6.0.0",
47
+ "@types/update-notifier": "^6.0.8",
48
+ "chalk": "4",
49
+ "commander": "^14.0.2",
42
50
  "listr2": "^8.0.0",
43
51
  "minimatch": "^10.1.1",
44
- "simple-git": "^3.22.0"
52
+ "simple-git": "^3.22.0",
53
+ "update-notifier": "^7.3.1"
45
54
  },
46
55
  "engines": {
47
56
  "node": ">=14.0.0"